#!/usr/bin/env python3 """kbots Interactive Setup Wizard. Complete setup from clone to running agent — dependencies, overlay, vault, Discord, agents, systemd, everything. Usage: uv run python setup.py """ import getpass import json import os import re import secrets as py_secrets import shutil import stat import subprocess import sys import urllib.error import urllib.request from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent # Engine root the deployment runs from. Defaults to the checkout the wizard # was started in; step_install may repoint it at a dedicated install clone so # the working repo stays disconnected from the running service. ENGINE_ROOT = PROJECT_ROOT try: import yaml except ImportError: print("PyYAML not found. Run: uv sync") sys.exit(1) try: from src.core.agent_scaffold import ( AGENT_NAME_RULE, agent_name_error, scaffold_agent, suggest_agent_name, ) from src.core.base import resolve_vault_key_file, write_private_file from src.vault.fernet import FernetVault except ImportError: print("kbots modules not found. Run: uv sync") sys.exit(1) # --- Formatting --- BOLD = "\033[1m" DIM = "\033[2m" GREEN = "\033[32m" YELLOW = "\033[33m" RED = "\033[31m" CYAN = "\033[36m" RESET = "\033[0m" TAGLINE = "one process · LLM-agnostic · trains itself" # Vault passphrases gate every stored credential; with the KDF alone a short # one is trivially brute-forceable if secrets.enc + .salt ever leak. MIN_PASSPHRASE_LEN = 12 def banner(title: str = "kbots Setup Wizard"): """Boxed banner, built rather than hand-drawn. The two hand-drawn boxes had drifted: settings.py rendered 38 characters inside a 40-character border, so it printed crooked. Centring in code means a retitle cannot misalign it again. """ lines = [title, TAGLINE] inner = max(len(line) for line in lines) + 8 top = "╔" + "═" * inner + "╗" bottom = "╚" + "═" * inner + "╝" body = "\n".join(f"║{line.center(inner)}║" for line in lines) print(f"\n{BOLD}{CYAN}{top}\n{body}\n{bottom}{RESET}\n") _PROGRESS = {"current": 0, "total": 0} def header(text: str): # While main() drives the step list, replace each step's hardcoded # "Step N:" prefix with a live "Step k/total:" — the user sees how far # along they are, and the numbering can't drift from the list again. m = re.match(r"Step \d+[a-z]?: (.*)", text) if m and _PROGRESS["total"]: text = f"Step {_PROGRESS['current']}/{_PROGRESS['total']}: {m.group(1)}" print(f"\n{BOLD}{CYAN}── {text} ──{RESET}\n") def ok(text: str): print(f" {GREEN}✓{RESET} {text}") def warn(text: str): print(f" {YELLOW}!{RESET} {text}") def err(text: str): print(f" {RED}✗{RESET} {text}") def info(text: str): print(f" {DIM}{text}{RESET}") def ask(prompt: str, default: str = "") -> str: suffix = f" [{default}]" if default else "" result = input(f" {prompt}{suffix}: ").strip() return result or default def ask_yn(prompt: str, default: bool = True) -> bool: yn = "[Y/n]" if default else "[y/N]" result = input(f" {prompt} {yn}: ").strip().lower() if not result: return default return result in ("y", "yes") def ask_secret(prompt: str) -> str: return getpass.getpass(f" {prompt}: ").strip() def ask_choice(prompt: str, options: list[str], default: str = "") -> str: for i, opt in enumerate(options, 1): marker = " (default)" if opt == default else "" print(f" {i}) {opt}{marker}") while True: result = input(f" {prompt}: ").strip() if not result and default: return default if result in options: return result try: idx = int(result) - 1 if 0 <= idx < len(options): return options[idx] except ValueError: pass print(f" Please enter 1-{len(options)} or a valid option.") def ask_menu(prompt: str, options: list[str], default: int = 1) -> int: """Print a numbered menu once and return the chosen 1-based index.""" for i, opt in enumerate(options, 1): marker = " (default)" if i == default else "" print(f" {i}) {opt}{marker}") while True: result = input(f" {prompt}: ").strip() if not result: return default try: n = int(result) if 1 <= n <= len(options): return n except ValueError: pass print(f" Please enter a number 1-{len(options)}.") def _is_snowflake(s: str) -> bool: """A Discord ID is a numeric snowflake, 17-20 digits.""" return s.isdigit() and 17 <= len(s) <= 20 def ask_id(prompt: str, required: bool = False) -> str: """Ask for a single Discord ID, validating the snowflake shape. Re-prompts on obviously-wrong input; blank skips unless required. """ while True: v = ask(prompt) if not v: if required: err("Required — paste the Discord ID (enable Developer Mode, " "right-click → Copy ID).") continue return "" if _is_snowflake(v): return v err("That doesn't look like a Discord ID (should be 17-20 digits). " "Right-click the server/user/channel → Copy ID.") if not required and not ask_yn("Try again?", default=True): return "" def ask_ids(label: str) -> list[str]: """Collect zero or more Discord IDs, validated and de-duplicated.""" info(f"Add {label} ID(s) one per line. Press Enter on a blank line when done.") ids: list[str] = [] while True: v = input(f" {label} ID (Enter when done): ").strip() if not v: break if not _is_snowflake(v): err("Not a Discord ID (17-20 digits) — skipped.") continue if v in ids: warn(f"{v} already added — skipping duplicate.") continue ids.append(v) ok(f"Added {label} {v}") return ids # --- Discord API --- def _looks_like_discord_token(s: str) -> bool: """Heuristic: does this string look like a Discord bot token? Bot tokens are dot-separated base64url segments (id.timestamp.hmac), ~59-72 chars. Used so a token pasted at a yes/no prompt isn't mistaken for 'no' and silently skipped. """ s = s.strip() parts = s.split(".") return ( len(parts) >= 2 and len(s) >= 50 and all(c.isalnum() or c in "._-" for c in s) ) def validate_discord_token(token: str) -> tuple[dict | None, str]: """Validate a Discord bot token by calling the API. Returns (bot_info, "") on success, (None, reason) on failure — where reason is "invalid" for a rejected token and "network" when Discord could not be reached. The two must stay distinguishable: an invalid token should be re-entered, while a network failure is a legitimate reason to store the token unverified (air-gapped installs). A User-Agent is required — Discord's API is fronted by Cloudflare, which blocks header-less requests with a 403 (error 1010) before the token is ever checked. Without it, every valid token would look invalid. """ try: req = urllib.request.Request( "https://discord.com/api/v10/users/@me", headers={ "Authorization": f"Bot {token.strip()}", "User-Agent": "DiscordBot (https://github.com/karvidsson/kbots, 1.0)", }, ) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode()), "" except urllib.error.HTTPError: return None, "invalid" except urllib.error.URLError: return None, "network" def ask_discord_token(prompt: str) -> str: """Collect a bot token without echoing it. Tokens grant full control of the bot account, and a bare input() leaves them in terminal scrollback and session recordings — so entry is hidden, always, rather than behind an opt-in. """ return ask_secret(f"{prompt} (input hidden, Enter to skip)") # Discord permission bits for the invite URL — the minimal set kbots needs # (README → Quickstart Step 1). Administrator is deliberately absent: it # would hand any leaked token or prompt-injected agent action full control # of the server. Manage Channels goes to the setup account alone, so server # auto-setup can create the fleet channels. _INVITE_PERMS = ( (1 << 6) # Add Reactions — HITL approval cards, reply "show more" | (1 << 10) # View Channels | (1 << 11) # Send Messages | (1 << 14) # Embed Links | (1 << 15) # Attach Files | (1 << 16) # Read Message History | (1 << 26) # Change Nickname — identity boot sets the agent's own nick ) _PERM_MANAGE_CHANNELS = 1 << 4 def invite_url(app_id: str, manage_channels: bool = False) -> str: """The OAuth2 install link for a bot application.""" perms = _INVITE_PERMS | (_PERM_MANAGE_CHANNELS if manage_channels else 0) return ("https://discord.com/oauth2/authorize" f"?client_id={app_id}&scope=bot%20applications.commands" f"&permissions={perms}") def _application_id(token: str, bot_info: dict | None) -> str: """The application (client) id an invite URL needs. /oauth2/applications/@me is authoritative; the bot USER id from /users/@me equals it for modern applications and covers the offline-validated case. """ try: req = urllib.request.Request( "https://discord.com/api/v10/oauth2/applications/@me", headers={ "Authorization": f"Bot {token.strip()}", "User-Agent": "DiscordBot (https://github.com/karvidsson/kbots, 1.0)", }, ) with urllib.request.urlopen(req, timeout=10) as resp: app_id = json.loads(resp.read().decode()).get("id", "") if app_id: return str(app_id) except (urllib.error.URLError, urllib.error.HTTPError): pass return str((bot_info or {}).get("id", "") or "") def show_invite_link(state: dict, bot_name: str, token: str, bot_info: dict | None, manage_channels: bool = False) -> None: """Print a bot's server-install link and remember it for the summary. Silent when Discord is unreachable and the token was never validated — there is nothing to build the link from, and the air-gapped install that hits this path has no use for an OAuth URL anyway. """ app_id = _application_id(token, bot_info) if not app_id: return url = invite_url(app_id, manage_channels) info("Install link — open it to add this bot to a server (grants the " "minimal permission set kbots needs; no Administrator):") print(f" {CYAN}{url}{RESET}") state.setdefault("invite_urls", {})[bot_name] = url # ========================================================================== # Steps # ========================================================================== _LINUX_PKG_MANAGERS = ( ("apt-get", ["sudo", "apt-get", "install", "-y"]), ("dnf", ["sudo", "dnf", "install", "-y"]), ("yum", ["sudo", "yum", "install", "-y"]), ("pacman", ["sudo", "pacman", "-S", "--noconfirm"]), ("zypper", ["sudo", "zypper", "install", "-y"]), ("apk", ["sudo", "apk", "add"]), ) def _linux_pkg_install_cmd(package: str) -> list[str] | None: """The install command for `package` on this Linux, or None if unknown.""" for binary, prefix in _LINUX_PKG_MANAGERS: if shutil.which(binary): return prefix + [package] return None def step_dependencies(state: dict): header("Step 1: Dependencies") # Python version v = sys.version_info if v.major >= 3 and v.minor >= 12: ok(f"Python {v.major}.{v.minor}.{v.micro}") else: err(f"Python {v.major}.{v.minor} — 3.12+ required") sys.exit(1) # uv if shutil.which("uv"): ok("uv") else: # Ask first, like every other curl|sh in this step (pkgx, Claude CLI) — # piping a remote script to a shell is a trust decision, not a detail. info("uv not found. It will be installed by piping " "https://astral.sh/uv/install.sh to sh.") if not ask_yn("Install uv now?", default=True): err("uv is required. Install it manually (https://docs.astral.sh/uv/) " "and re-run setup.") sys.exit(1) try: subprocess.run( ["bash", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], check=True, ) ok("uv installed") except subprocess.CalledProcessError: err("Failed to install uv. Install manually: https://docs.astral.sh/uv/") sys.exit(1) # jq (optional but useful) if shutil.which("jq"): ok("jq") else: warn("jq not found") if ask_yn("Install now?"): if sys.platform == "darwin": if shutil.which("brew"): try: subprocess.run(["brew", "install", "jq"], check=True) ok("jq installed") except subprocess.CalledProcessError: warn("Failed to install jq — install manually: brew install jq") else: warn("Homebrew not found — install jq manually: https://jqlang.org/download/") else: # Not every Linux is Debian: pick the package manager that is # actually on PATH rather than assuming apt-get. cmd = _linux_pkg_install_cmd("jq") if cmd is None: warn("No known package manager found — install jq manually: " "https://jqlang.org/download/") else: try: subprocess.run(cmd, check=True) ok("jq installed") except subprocess.CalledProcessError: warn(f"Failed to install jq — install manually: {' '.join(cmd)}") # pkgx (optional): zero-install CLI provider — media tools (ffmpeg), tmux, # and agents' Bash can then run CLI deps on demand, cached, no sudo. if shutil.which("pkgx"): ok("pkgx (zero-install CLI provider)") else: info("Optional: pkgx (4MiB) lets kbots run CLI tools like ffmpeg on") info("demand with no install step (https://pkgx.sh).") if ask_yn("Install pkgx?", default=False): try: subprocess.run(["bash", "-c", "curl -fsSL https://pkgx.sh | sh"], check=True) ok("pkgx installed" if shutil.which("pkgx") else "pkgx installed (restart your shell to pick up PATH)") except subprocess.CalledProcessError: warn("pkgx install failed — skip; everything works without it") # Claude Code CLI if shutil.which("claude"): ok("Claude Code CLI") else: warn("Claude Code CLI not found") if ask_yn("Install now?"): try: subprocess.run( ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | sh"], check=True, ) if shutil.which("claude"): ok("Claude Code CLI installed") else: warn("Installed but not on PATH — restart your shell") except subprocess.CalledProcessError: err("Installation failed") info("Install manually: https://docs.anthropic.com/en/docs/claude-code") else: info("Install later: curl -fsSL https://claude.ai/install.sh | sh") # Claude Code authentication — agents can't respond without it if shutil.which("claude"): while True: try: result = subprocess.run( ["claude", "auth", "status"], capture_output=True, text=True, timeout=20, ) authed = result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): authed = False if authed: ok("Claude Code authenticated") break warn("Claude Code CLI is not authenticated — agents cannot respond without it.") info("In ANOTHER terminal run: claude auth login (Pro/Max plan; add --console for API billing)") choice = ask("Press Enter to re-check, or 's' to skip", "") if choice.lower() == "s": warn("Skipped — agents will fail until you run: claude auth login") break # Sync Python deps info("Installing Python dependencies...") sync_script = PROJECT_ROOT / "scripts" / "sync.sh" if sync_script.exists(): subprocess.run([str(sync_script)], cwd=str(PROJECT_ROOT), check=False) else: subprocess.run(["uv", "sync"], cwd=str(PROJECT_ROOT), check=False) ok("Dependencies installed") def step_install(state: dict): """Deploy the engine to a dedicated install directory. The running service must be disconnected from the working checkout: editing/pulling the repo you develop in must never touch a live deployment. We git-clone the engine into an install dir and every later step (overlay, services, MCP paths) points at that clone. Updating the live install is an explicit act: scripts/update.sh in the install dir. """ global ENGINE_ROOT header("Step 2: Install Location") info("The service runs from a dedicated engine clone, separate from this") info("checkout — so editing this repo never touches the live deployment.") print() default_dir = str(Path.home() / "kbots") if sys.platform == "darwin" else "/opt/kbots" install_input = ask("Engine install directory", default_dir) install_dir = Path(install_input).expanduser().resolve() if install_dir == PROJECT_ROOT: warn("Installing in place — this checkout IS the live engine.") warn("Edits and pulls here affect the running service directly.") ENGINE_ROOT = PROJECT_ROOT state["engine_root"] = ENGINE_ROOT return if (install_dir / ".git").is_dir(): ok(f"Existing engine clone found: {install_dir}") else: if install_dir.exists() and any(install_dir.iterdir()): err(f"{install_dir} exists and is not an engine clone — choose another directory.") sys.exit(1) # Create the parent (may need sudo for /opt on Linux). A target that # already exists (empty) and is writable needs nothing from its parent: # `sudo mkdir /opt/kbots && sudo chown $USER /opt/kbots` is the # documented no-passwordless-sudo path, and it must not be rejected # because /opt itself is root-owned. parent = install_dir.parent if install_dir.is_dir() and os.access(install_dir, os.W_OK): pass elif not os.access(parent if parent.exists() else parent.parent, os.W_OK): if _check_sudo(): subprocess.run(["sudo", "-n", "mkdir", "-p", str(parent)], check=True) subprocess.run( ["sudo", "-n", "chown", getpass.getuser(), str(parent)], check=True ) else: err(f"No write access to {parent} and no passwordless sudo.") info(f"Create it manually (sudo mkdir -p {install_dir} && " f"sudo chown $USER {install_dir}) and re-run.") sys.exit(1) info(f"Cloning engine to {install_dir} ...") try: subprocess.run( ["git", "clone", "--local", str(PROJECT_ROOT), str(install_dir)], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: err(f"Clone failed: {e.stderr.strip()}") sys.exit(1) # We created this clone this run — remove it entirely on abort. _track_path(state, install_dir) ok(f"Engine cloned to {install_dir}") info(f"Updates: cd {install_dir} && scripts/update.sh (pulls from this checkout)") # Sync deps inside the install clone so its .venv exists info("Installing engine dependencies in the install directory...") subprocess.run(["uv", "sync"], cwd=str(install_dir), check=False) ENGINE_ROOT = install_dir state["engine_root"] = ENGINE_ROOT ok(f"Engine root: {ENGINE_ROOT}") def step_overlay(state: dict): header("Step 3: Deployment Overlay") info("The overlay holds your config, agent identities, service files,") info("and generated files — separate from the engine code.") info("This keeps Core clean for updates.") print() # Default: sibling of the engine install named -overlay parent = ENGINE_ROOT.parent base = ENGINE_ROOT.name # Strip version suffixes (kbots-v2 -> kbots) for suffix in ["-v2", "-v3", "-v4"]: base = base.replace(suffix, "") default_overlay = str(parent / f"{base}-overlay") overlay_input = ask("Overlay directory", default_overlay) overlay = Path(overlay_input).resolve() # If we're creating the overlay fresh, remove the whole tree on abort. # If it already existed, leave it (don't nuke a user's existing data). if not overlay.exists(): _track_path(state, overlay) # Create directory structure. tools/ and skills/ exist upfront so the # hot-reload watcher registers them at boot (create_tool/create_skill). # codex/ must exist BEFORE any agent is scaffolded: agent_session_dirs # grants only directories that exist, so a missing codex meant every # agent's generated settings silently omitted the shared-documents dir # ("Agent workspace directory does not exist, not granting it"). for d in ["config", "agents", "systemd", "tools", "skills", "codex", "tmp/media", "tmp/docs", "tmp/scratch"]: (overlay / d).mkdir(parents=True, exist_ok=True) # config/ holds the vault; tmp/ holds agent scratch, fetched media and # docs. Owner-only, so other local users can't list key names or read # whatever agents drop there. chmod rather than mkdir(mode=...) so a # pre-existing overlay gets tightened too. for private in ("config", "tmp"): try: (overlay / private).chmod(0o700) except OSError as e: warn(f"Could not tighten permissions on {overlay / private}: {e}") # .gitignore gitignore = overlay / ".gitignore" if not gitignore.exists(): gitignore.write_text( "# Runtime data\n" "agents/*/data/\n" "**/MEMORY_CONTEXT.md\n" "\n" "# Agent-generated files\n" "tmp/\n" "\n" "# Python\n" "__pycache__/\n" "*.pyc\n" "\n" "# Claude Code session state\n" "/.claude/\n" "\n" "# Secrets\n" "config/secrets.enc\n" "config/secrets.salt\n" "config/secrets.kdf\n" ) # Add kbots env vars to the shell profile so interactive tools # (settings.py etc.) work. Pick the profile the user's shell actually # reads: zsh (macOS default) → ~/.zshrc, else ~/.bashrc. shell = os.environ.get("SHELL", "") if "zsh" in shell or (sys.platform == "darwin" and not shell): profile = Path.home() / ".zshrc" else: profile = Path.home() / ".bashrc" content = profile.read_text() if profile.exists() else "" if "KBOTS_OVERLAY" not in content: env_block = f"\n# kbots environment\nexport KBOTS_OVERLAY={overlay}\n" if state.get("kbots_modules"): env_block += f"export KBOTS_MODULES={state['kbots_modules']}\n" _write_profile_block(profile, env_block) _track_undo(state, f"~/{profile.name} env exports", lambda p=profile, b=env_block: _strip_profile_block(p, b)) ok(f"Added kbots env vars to ~/{profile.name}") elif _relocate_profile_block(profile): ok(f"Moved kbots env vars above the interactive guard in ~/{profile.name} " f"(they were invisible to cron and non-interactive ssh)") else: info(f"KBOTS_OVERLAY already in ~/{profile.name}") ok(f"Overlay: {overlay}") state["overlay"] = overlay # Export into the wizard's own process too, not just the shell profile: # later steps (scaffold_agent → agent_session_dirs) read KBOTS_OVERLAY at # call time, and the profile export only reaches new shells. Without this, # a first run generated agent sandbox rules pointing at /tmp instead of # /tmp — an install that only healed when setup was re-run from # a fresh shell. os.environ["KBOTS_OVERLAY"] = str(overlay) def step_hooks(state: dict): header("Step 4: Git Hooks") hooks_src = ENGINE_ROOT / "hooks" hooks_dst = ENGINE_ROOT / ".git" / "hooks" if not hooks_src.exists() or not hooks_dst.exists(): info("No hooks to install — skipping") return installed = 0 for hook in sorted(hooks_src.iterdir()): if not hook.is_file(): continue target = hooks_dst / hook.name if target.exists(): if hook.read_text() != target.read_text(): if ask_yn(f"Hook '{hook.name}' differs from shipped version. Update?"): shutil.copy2(hook, target) target.chmod(target.stat().st_mode | stat.S_IEXEC) ok(f"Updated hook: {hook.name}") installed += 1 else: info(f"Hook kept: {hook.name}") else: info(f"Hook up to date: {hook.name}") else: shutil.copy2(hook, target) target.chmod(target.stat().st_mode | stat.S_IEXEC) ok(f"Installed hook: {hook.name}") installed += 1 if installed == 0: ok("All hooks up to date") def step_modules(state: dict): header("Step 5: Deployment Pattern & Extension Modules") info("kbots supports two deployment patterns:") print() print(f" {BOLD}[1] 2-layer{RESET} — Core + Overlay (single deployment, recommended)") info(" All tools/skills live in Core. Overlay holds your config, agents, data.") print() print(f" {BOLD}[2] 3-layer{RESET} — Core + Extension Modules + Overlay") info(" Use when one extension module set feeds multiple overlays") info(" (e.g. shared domain tools across staging/prod or client installs).") print() # Show core tools print(f" {BOLD}Core tools (always included):{RESET}") tools_dir = ENGINE_ROOT / "src" / "tools" if tools_dir.exists(): for py in sorted(tools_dir.glob("*.py")): if py.name.startswith("__"): continue print(f" {GREEN}✓{RESET} {py.stem}") # Show core skills skills_dir = ENGINE_ROOT / "skills" core_skills = [] if skills_dir.exists(): for yml in sorted(list(skills_dir.glob("*.yaml")) + list(skills_dir.glob("*.yml"))): core_skills.append(yml.stem) if core_skills: print(f"\n {BOLD}Core skills (always included):{RESET}") for s in core_skills: print(f" {GREEN}✓{RESET} {s}") # Scan for Layer 2 modules up front so we can default the pattern intelligently modules_root = None for candidate in [ENGINE_ROOT.parent / "kbots-modules", ENGINE_ROOT.parent / "modules"]: if candidate.is_dir(): modules_root = candidate break # Default to 2-layer unless extension modules are present on disk default_pattern = "1" if modules_root is None else "2" print() # Validate rather than treating anything that isn't "1" as 3-layer: a typo # here used to silently pick the advanced pattern and then ask for a # modules path nobody has. while True: pattern = ask("Deployment pattern [1=2-layer, 2=3-layer]", default_pattern).strip() if pattern in ("1", "2-layer"): state["deployment_pattern"] = "2-layer" break if pattern in ("2", "3-layer"): state["deployment_pattern"] = "3-layer" break err("Please enter 1 (2-layer) or 2 (3-layer).") if state["deployment_pattern"] == "2-layer": print() ok("2-layer deployment: Core + Overlay only") state["kbots_modules"] = "" state["selected_modules"] = [] return if not modules_root: print() modules_input = ask("Path to extension modules (leave blank to skip)") if modules_input: p = Path(modules_input).resolve() if p.is_dir(): modules_root = p if not modules_root or not modules_root.is_dir(): print() warn("3-layer selected but no extension module directory found — falling back to 2-layer") state["deployment_pattern"] = "2-layer" state["kbots_modules"] = "" state["selected_modules"] = [] return # List available modules available = [] print(f"\n {BOLD}Available extension modules:{RESET}") idx = 0 for mod_dir in sorted(modules_root.iterdir()): if not mod_dir.is_dir(): continue idx += 1 mod_tools = [] if (mod_dir / "tools").exists(): mod_tools = [p.stem for p in (mod_dir / "tools").glob("*.py") if not p.name.startswith("__")] mod_skills = [] skills_p = mod_dir / "skills" if skills_p.exists(): mod_skills = [p.stem for p in sorted(list(skills_p.glob("*.yaml")) + list(skills_p.glob("*.yml")))] available.append(mod_dir.name) print(f"\n {BOLD}[{idx}] {mod_dir.name}{RESET}") if mod_tools: print(f" Tools: {' '.join(mod_tools)}") if mod_skills: print(f" Skills: {' '.join(mod_skills)}") if not available: ok("No extension modules found") state["kbots_modules"] = "" state["selected_modules"] = [] return print() info("Enter module numbers (comma-separated), 'all', or leave blank to skip.") choice = ask("Modules", "") selected = [] if choice.lower() == "all": selected = available[:] elif choice: for c in choice.split(","): c = c.strip() try: i = int(c) - 1 if 0 <= i < len(available): selected.append(available[i]) except ValueError: pass if selected: modules_paths = [str(modules_root / mod) for mod in selected] state["kbots_modules"] = ":".join(modules_paths) state["kbots_modules_root"] = str(modules_root) state["selected_modules"] = selected # Same reasoning as KBOTS_OVERLAY in step_overlay: later steps in this # process must see it, not just future shells. os.environ["KBOTS_MODULES"] = state["kbots_modules"] print() for mod in selected: ok(f"Module: {mod}") else: state["kbots_modules"] = "" state["selected_modules"] = [] ok("No extension modules selected (core tools only)") def _offer_kdf_upgrade(vault: FernetVault, passphrase: str) -> None: """Offer to rekey a vault still on legacy KDF parameters. Without this, a vault created before the 600k-iteration default stayed at 100k (and possibly the shared legacy salt) through every setup re-run — the wizard early-returns on a working key file, so there was no moment the upgrade could ever happen. """ if vault.kdf_current(): return info("This vault still uses legacy key-derivation parameters (weaker " "against brute force if the files ever leak).") if ask_yn("Upgrade it now? (re-encrypts in place, same passphrase)", default=True): changed = vault.rekey(passphrase) for what, detail in changed.items(): ok(f"Vault {what}: {detail}") def step_vault(state: dict): header("Step 6: Vault Setup") info("The vault encrypts your API tokens and secrets at rest.") info("You'll set a passphrase that's needed to unlock the vault at startup.") overlay: Path = state["overlay"] vault_path = overlay / "config" / "secrets.enc" key_file = resolve_vault_key_file() vault = FernetVault(str(vault_path)) # The key file lives outside the overlay (~/.config) — track it for cleanup # if we're about to create it fresh. The vault itself is inside the overlay. if not key_file.exists(): _track_path(state, key_file) if not vault_path.exists(): _track_path(state, vault_path) _track_path(state, vault_path.with_suffix(".salt")) # .kdf records the KDF iteration count. An orphan from an aborted run # poisons the next vault: it would be read as authoritative for a # vault created with different parameters. _track_path(state, vault_path.with_suffix(".kdf")) if vault_path.exists(): info("Existing vault found.") # Try key file first if key_file.exists(): try: passphrase = key_file.read_text().strip() vault.unlock(passphrase) ok(f"Vault unlocked ({len(vault.list_keys())} secrets)") _offer_kdf_upgrade(vault, passphrase) state["vault"] = vault state["vault_existed"] = True return except ValueError: warn("Key file passphrase didn't work.") for attempt in range(3): passphrase = ask_secret("Enter vault passphrase") try: vault.unlock(passphrase) ok(f"Vault unlocked ({len(vault.list_keys())} secrets)") _offer_kdf_upgrade(vault, passphrase) if ask_yn("Save passphrase to key file for auto-unlock?"): write_private_file(key_file, passphrase + "\n") ok(f"Key file saved to {key_file}") state["vault"] = vault state["vault_existed"] = True return except ValueError: err("Wrong passphrase.") err("Failed to unlock vault after 3 attempts.") sys.exit(1) else: while True: passphrase = ask_secret( "Create a vault passphrase (Enter to generate one)") if not passphrase: passphrase = py_secrets.token_urlsafe(24) print(f" Generated passphrase: {BOLD}{passphrase}{RESET}") info("Shown only once — store it in a password manager. It is the") info("only way to unlock the vault if the key file is ever lost.") break if len(passphrase) < MIN_PASSPHRASE_LEN: err(f"Passphrase must be at least {MIN_PASSPHRASE_LEN} characters " f"— or press Enter to generate a strong one.") continue confirm = ask_secret("Confirm passphrase") if passphrase != confirm: err("Passphrases don't match.") continue break vault.unlock(passphrase) # Persist empty vault to create the file vault.set("_setup", "true") vault.delete("_setup") ok("Vault created") # The key file trades encryption-at-rest for unattended start: the # service reads the passphrase from it at boot, and so can anyone else # who can read the file. That trade is fine for most installs, but it # has to be a choice the user saw — SECURITY.md calls any unencrypted # secret on disk a vulnerability, and this is the one sanctioned # exception. print() info("The service needs this passphrase at every start. Saving it to a") info(f"key file ({key_file}, owner-only 0600) lets the service start") info("unattended after a reboot — but anyone who can read that file can") info("unlock the vault.") if ask_yn("Save passphrase to the key file for unattended start?", default=True): write_private_file(key_file, passphrase + "\n") ok(f"Key file saved to {key_file}") else: warn("No key file — the service cannot auto-start after a reboot.") warn("Start it in a terminal (it will prompt), or create the key " "file later by re-running setup.") state["vault"] = vault state["vault_existed"] = False def ask_display_name(prompt: str, default: str) -> tuple[str, str]: """One name question instead of three. The user gives the Discord-facing name they actually want ('Atlas'); the internal name — folder, config key, bot account — is derived from it ('atlas', 'My Bot 2' → 'my-bot-2') instead of being asked separately. Only when nothing usable survives derivation does the question repeat. """ while True: display = ask(prompt, default) or default internal = suggest_agent_name(display) if internal and not agent_name_error(internal): info(f"Internal name: {internal} (folder, config key, bot account)") return display, internal err(f"Couldn't derive an internal name from {display!r} " f"— it must contain letters ({AGENT_NAME_RULE}).") def step_discord(state: dict): header("Step 7: Main Agent & Discord Bot") info("Your primary AI agent and the Discord bot account it speaks through") info("— one agent, one bot, so it's one step.") print() vault: FernetVault = state["vault"] display_name, internal = ask_display_name( "Agent name (shown in Discord — you'll @mention this)", "Main") description = ask("One-line description", "Primary agent") model = ask_choice("LLM model", ["sonnet", "opus"], default="sonnet") info("Optionally set a personality for the agent's identity file (AGENTS.md).") personality = ask("Personality (e.g., 'concise and direct', " "'friendly and detailed')", "concise and direct") state["agent"] = { "name": internal, "display_name": display_name, "description": description, "model": model, "personality": personality, } state["bot_name"] = internal state["bot_token_key"] = "discord-token" print() info("Now the bot account itself.") info("Create one at: https://discord.com/developers/applications") info("Required intents: Message Content, Server Members, Guild Messages") info("Tip: README → QUICKSTART Step 1 has a Claude-in-Chrome prompt that") info("automates the whole portal setup (except copying the token).") print() # A re-run against an existing vault shouldn't force a re-paste — the # token is already stored, and hunting it down again is the most annoying # part of a second pass through the wizard. token = "" if state.get("vault_existed") and vault.get("discord-token"): if ask_yn("A Discord bot token is already in the vault. Keep it?", default=True): token = vault.get("discord-token") if not token: token = ask_discord_token("Paste your bot token") while token: info("Validating token...") bot_info, reason = validate_discord_token(token) if bot_info: ok(f"Bot verified: {bot_info.get('username', '?')}#{bot_info.get('discriminator', '0')}") break if reason == "network": # Air-gapped or offline installs can't reach Discord — storing the # token unverified is a legitimate choice there. A rejected token # is not: keeping it just moves the failure to first boot. if ask_yn("Couldn't reach Discord to validate. Store the token " "unverified?", default=False): break else: err("Discord rejected that token — re-copy it from the Developer " "Portal (Bot → Reset Token if it was regenerated).") token = ask_discord_token("Paste your bot token") if not token: warn("Skipped — add a token later via vault-manage.py, then restart.") state["discord_skip"] = True else: vault.set("discord-token", token) ok("Token stored in vault") # Guild ID + your user ID (validated snowflakes) info("Tip: enable Discord Developer Mode (Settings → Advanced), then " "right-click → Copy ID. Or read the server ID from the URL: " "discord.com/channels//.") guild_id = ask_id("Server (guild) ID") state["guild_id"] = guild_id user_id = ask_id("Your Discord user ID (makes you the admin)") state["owner_discord_id"] = user_id state["discord_skip"] = False # Manage Channels: the main bot is the setup account — server # auto-setup creates the fleet channels through it on guild join. print() show_invite_link(state, internal, token, bot_info, manage_channels=True) # Routing (asked even when Discord was skipped — the agent config must # exist either way, and the account wiring activates once a token lands). print() state["agent"]["routing"] = _ask_routing(internal) ok(f"Agent: {display_name} ({model})") def step_team(state: dict): header("Step 8: Team — Owner Profile") info("Set up your profile so the system knows who you are.") print() # The name doubles as the owner's team.json id — a blank one produces # an entry with id "" that nothing can address. while True: name = ask("Your name") if name: break err("Required — a name is needed for the owner profile.") role = ask("Your role", "Founder") timezone = ask("Timezone (e.g. Europe/Stockholm, America/New_York, or UTC)", "UTC") context = ask("Short context (what you do)") discord_id = state.get("owner_discord_id", "") if not discord_id: discord_id = ask_id("Your Discord user ID") state["owner"] = { "id": name.lower().replace(" ", "-"), "name": name, "type": "human", "access": "owner", "role": role, "responsibilities": ["Everything"], "context": context, "contact": {"discord": discord_id}, "preferences": {"timezone": timezone}, } state["team_members"] = [state["owner"]] ok(f"Owner profile: {name} ({role})") def step_full_control(state: dict): header("Step 10: Main Agent — Machine Control") info("By default the main agent has NO shell access — it can read files and") info("use tools, but cannot run commands or edit the system.") print() info("You can give it full control of this machine:") info(f" {BOLD}none{RESET} — default: read + tools only, no shell (safest)") info(f" {BOLD}user{RESET} — full shell + file access as your account (no sudo)") info(f" {BOLD}root{RESET} — everything, PLUS passwordless sudo (can act as root)") print() warn("Full control means the agent can run anything you can. Only enable it") warn("on a machine you trust to your agent.") print() level = ask_choice( "Control level", ["none", "user", "root"], default="none" ) state["main_full_control"] = level if level == "none": ok("Main agent: read + tools only (no shell)") return # user/root → the main agent is scaffolded as a privileged agent state["agent"]["full_control"] = True ok(f"Main agent will have {BOLD}{level}-level{RESET} control (privileged, full shell)") if level == "root": print() info("Installing passwordless sudo (you'll be asked for your password once)...") script = ENGINE_ROOT / "scripts" / "full-control.sh" if script.exists(): rc = subprocess.run(["bash", str(script), "grant"]).returncode if rc == 0: _track_undo(state, "passwordless sudo rule", lambda s=script: subprocess.run(["bash", str(s), "revoke"])) ok("Passwordless sudo granted — the agent can act as root") info("Revoke any time: scripts/full-control.sh revoke") else: err("Could not install sudo rule — agent has user-level control only") info(f"Grant later: bash {script} grant") else: warn(f"Script not found: {script} — grant sudo manually later") def step_hitl(state: dict): header("Step 11: Human-in-the-Loop Approval") info("Some tools require human approval before executing.") info("Approvals are sent to a Discord channel as reaction prompts.") print() default_gated = ["send_email", "install_mcp", "create_agent", "create_tool", "promote_tool", "create_trigger", "delete_trigger"] # Whether the approval gate starts on. Toggle live later with /admin hitl. enabled = True if state.get("main_full_control", "none") != "none": info("Your main agent has full machine control.") info("The approval gate makes it ask you before sensitive/destructive actions.") enabled = ask_yn("Keep the human-approval gate ON? (recommended)", default=True) if not enabled: warn("Approval gate OFF — the agent acts fully autonomously.") info("You can flip this any time in Discord: /admin hitl on|off") print() if state.get("discord_skip"): warn("Discord not configured — using defaults for HITL.") state["hitl"] = { "channel": "", "approvers": [], "gated_tools": default_gated, "enabled": enabled, } return # Blank is the recommended answer: server auto-setup creates # #kbots-approvals when the main bot joins the guild and wires it live # (adopting an existing channel of that name, never repointing one set # here). The ID prompt exists for people who want an EXISTING channel # under a different name. ask_id, not ask: a typo would silently point # the fail-closed gate at a nonexistent channel and every gated action # would hang. info("Approvals channel: leave blank (recommended) and the main bot") info("creates + wires #kbots-approvals itself when it joins your server.") info("Enter an ID only to use an existing channel with a different name.") channel_id = ask_id("Approvals channel ID (Enter for automatic)") approvers = [state.get("owner_discord_id", "")] approvers = [a for a in approvers if a] if ask_yn("Add more approvers?", default=False): for uid in ask_ids("approver user"): if uid not in approvers: approvers.append(uid) print() info("Default gated tools: send_email, install_mcp, create_agent, create_tool, " "promote_tool, create_trigger") gated = list(default_gated) if ask_yn("Add more gated tools?", default=False): while True: tool = ask("Tool name (Enter when done)") if not tool: break if tool not in gated: gated.append(tool) state["hitl"] = { "channel": channel_id, "approvers": approvers, "gated_tools": gated, "enabled": enabled, } ok(f"HITL: {'ON' if enabled else 'OFF'}, {len(approvers)} approver(s), " f"{len(gated)} gated tool(s)") def step_compression(state: dict): header("Step 12: Context Compression (optional)") info("kbots can compress verbose context files (codex docs, skill prompts)") info("to reduce input tokens per agent message. Rule-based, no LLM calls.") info("Agent identity files (AGENTS.md) are excluded — carefully tuned prompts.") print() compression = {"enabled": False, "level": "standard"} if ask_yn("Enable context compression?", default=False): compression["enabled"] = True compression["level"] = ask_choice( "Compression level", ["lite", "standard"], default="standard", ) ok(f"Context compression enabled ({compression['level']})") print() info("Memory recall compression strips filler from memories before") info("injecting them into agent context. Same rules, applied at runtime.") print() if ask_yn("Enable memory recall compression?", default=False): compression["memory_recall"] = True ok("Memory recall compression enabled") else: compression["memory_recall"] = False else: info("Skipped — can be enabled later in config.yaml") state["compression"] = compression # --- Local models (Ollama / LM Studio) --- def _detect_ram_gb() -> int: """Total system RAM in GB (0 if undetectable).""" try: if sys.platform == "darwin": out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5) return round(int(out.stdout.strip()) / (1024 ** 3)) with open("/proc/meminfo") as f: kb = int(next(ln for ln in f if ln.startswith("MemTotal")).split()[1]) return round(kb / (1024 ** 2)) except Exception: return 0 def _probe_local_runtime() -> str | None: """Name of the running local-model runtime, if any.""" for name, url in (("Ollama", "http://localhost:11434/v1/models"), ("LM Studio", "http://localhost:1234/v1/models")): try: urllib.request.urlopen(url, timeout=2) return name except Exception: continue return None def _local_model_tiers(ram_gb: int) -> tuple[list[tuple[str, str, str]], int]: """(tiers, recommended 1-based index) for the detected RAM. Each tier: (label, router_model, workhorse_model). Models verified July 2026 (official Ollama library, all with tools support) — see docs/LOCAL_MODELS.md. """ is_mac = sys.platform == "darwin" tiers = [ ("Tiny — 8GB (VPS/CPU): granite4.1:3b router + qwen3.5:4b workhorse", "granite4.1:3b", "qwen3.5:4b"), ("Small — 16GB: qwen3.5:2b router + qwen3.5:9b workhorse", "qwen3.5:2b", "qwen3.5:9b"), ("Big — 32GB+: qwen3.5:4b router + qwen3.6:35b-a3b workhorse (MoE)", "qwen3.5:4b", "qwen3.6:35b-a3b"), ] if ram_gb and ram_gb < 12: rec = 1 elif ram_gb < 28: rec = 2 else: rec = 3 if not ram_gb: rec = 2 if is_mac else 1 return tiers, rec def step_local_models(state: dict): header("Step 12b: Local Models (optional)") info("Run simple requests on a LOCAL model (via Ollama or LM Studio) instead") info("of Claude — a tiny router model classifies each message and only") info("clearly-simple ones stay local (quality-first), saving Claude usage.") print() ram = _detect_ram_gb() runtime = _probe_local_runtime() if ram: info(f"Detected: {ram}GB RAM" + (f", {runtime} running" if runtime else ", no local runtime running")) if not runtime: info("Requires Ollama (ollama.com) or LM Studio (lmstudio.ai) — both are") info("auto-detected at runtime; you can install one later and re-enable.") print() if not ask_yn("Enable local models + tier routing?", default=bool(runtime)): info("Skipped — enable later: defaults.llm.local + defaults.llm.router " "in config.yaml (see docs/LOCAL_MODELS.md)") state["local_models"] = {"enabled": False} return tiers, rec = _local_model_tiers(ram) labels = [t[0] + (" ← recommended for this machine" if i + 1 == rec else "") for i, t in enumerate(tiers)] choice = ask_menu("Model tier", labels, default=rec) _, router_model, local_model = tiers[choice - 1] state["local_models"] = {"enabled": True, "router_model": router_model, "local_model": local_model} ok(f"Local models: router={router_model}, workhorse={local_model}") if shutil.which("ollama") and ask_yn("Pull these models with ollama now?", default=True): for m in (router_model, local_model): info(f"ollama pull {m} (this can take a while)…") try: subprocess.run(["ollama", "pull", m], timeout=1800) except Exception as e: warn(f"pull failed for {m}: {e} — run 'ollama pull {m}' manually") # --- Training-data collection --- def step_training(state: dict): header("Step 12c: Training-Data Collection (optional)") info("kbots can record every agent turn — prompt, tool-call trace, response,") info("and your 👍/👎 reactions — into a local dataset you can later use to") info("fine-tune a small local model on YOUR tools (see docs/TRAINING.md).") info("Everything stays on this machine; secrets are redacted. Off by default") info("because it stores full conversation content under /training/.") print() training = {"enabled": False, "include_tool_trace": True} if ask_yn("Enable training-data collection?", default=False): training["enabled"] = True ok("Training-data collection enabled → /training/") info("React 👍/👎 on agent replies — that's what labels the dataset.") else: info("Skipped — enable later: kbots.training_collection in config.yaml") state["training_collection"] = training # Optional dependency groups from pyproject.toml that a deployment can enable. # sync.sh reads /extras and passes each name as `uv sync --extra` on # every sync, so a choice made here survives updates. _PY_EXTRAS = [ ("embeddings", "semantic memory recall (local embeddings) — recommended"), ("data", "data-analysis stack (pandas, matplotlib, scipy, scikit-learn)"), ("reports", "PDF / report generation (fpdf2, markdown)"), ("search", "Tavily web search tool"), ("api", "HTTP API + webhook server (FastAPI)"), ("design", "PowerPoint generation (python-pptx)"), ("graph", "graph memory backend"), ("stagehand", "AI-driven browser automation"), ] def step_pyextras(state: dict): header("Step 13: Optional Features") overlay: Path = state["overlay"] extras_file = overlay / "extras" current: list[str] = [] if extras_file.exists(): current = extras_file.read_text().replace(",", " ").split() info("Optional feature sets (Python extras). The final dependency sync in") info("this run installs them, and every later sync keeps them installed.") print() for i, (name, desc) in enumerate(_PY_EXTRAS, 1): mark = f"{GREEN}✓{RESET}" if name in current else " " print(f" [{i}] {mark} {name} — {desc}") print() info("Enter numbers (comma-separated), 'all', or blank to keep as is.") choice = ask("Extras", "") if choice: if choice.lower() == "all": selected = [n for n, _ in _PY_EXTRAS] else: selected = [] for c in choice.split(","): try: i = int(c.strip()) - 1 if 0 <= i < len(_PY_EXTRAS): selected.append(_PY_EXTRAS[i][0]) except ValueError: pass extras_file.write_text("\n".join(selected) + ("\n" if selected else "")) ok(f"Extras: {' '.join(selected) or 'none'} (saved to {_display(extras_file, overlay)})") else: ok(f"Extras unchanged: {' '.join(current) or 'none'}") info("Vendor integrations (google, trello, notion, github, …) are separate —") info("they live in extras/ in the engine repo and install by copying into") info("the overlay. See extras/README.md.") def step_generate(state: dict): header("Step 13: Generating Config Files") overlay: Path = state["overlay"] agent = state["agent"] owner = state["owner"] hitl = state["hitl"] bot_name = state.get("bot_name", "main") guild_id = state.get("guild_id", "YOUR_GUILD_ID") owner_discord = owner["contact"]["discord"] or "YOUR_DISCORD_USER_ID" agent_dir = overlay / "agents" / agent["name"] # --- config.yaml --- # Only wire up the Discord account if its token was actually captured — # otherwise the service crash-loops at boot on a missing vault secret # (preflight is fail-closed). If skipped, ship discord disabled with an # example account so it's easy to fill in later. discord_ok = not state.get("discord_skip") if discord_ok: discord_cfg = { "enabled": True, "guild_id": guild_id if guild_id and guild_id != "YOUR_GUILD_ID" else "", "accounts": { bot_name: {"token_key": state.get("bot_token_key", "discord-token")}, }, } else: warn("Discord token wasn't set — writing config with Discord DISABLED.") info("Add a token later: uv run python vault-manage.py (key: discord-token)") info("then set connectors.discord.enabled: true and restart.") discord_cfg = { "enabled": False, "guild_id": "", "accounts": {"main": {"token_key": "discord-token"}}, } config = { "kbots": { "name": "kbots", "log_level": "info", "data_dir": "./data", # Always written (even when disabled) so the knob is discoverable "training_collection": state.get( "training_collection", {"enabled": False, "include_tool_trace": True}), }, "connectors": { "discord": discord_cfg, "http": {"enabled": False, "port": 8080}, }, "defaults": { "llm": {"provider": "claude_code", "model": agent["model"], "max_tokens": 4096}, "session": {"max_history": 50, "summarize_after": 30}, "memory": {"backend": "sqlite", "semantic_search": False, "decay_enabled": False, "max_results": 10}, }, "security": { "hitl": { "connector": "discord", "enabled": hitl.get("enabled", True), "channel": hitl["channel"], "approvers": hitl["approvers"], "timeout": 1800, "fail_mode": "closed", "poll_interval": 3, "gated_tools": hitl["gated_tools"], }, "rate_limits": {"mode": "log", "defaults": {"max_per_hour": 100}}, "compression": state.get("compression", {"enabled": False, "level": "standard"}), }, "admin_users": {"discord": [owner_discord]}, # ON for fresh installs: each agent aligns its Discord account name # and avatar with its config on first boot, so a reused bot app named # something else doesn't ship a stranger's identity. The example # config defaults this OFF because renaming is outward-facing and # rate-limited — a caveat for established fleets, not a new install. "identity": {"reconcile_on_boot": True}, } # Local models + tier routing (from step_local_models). Router config in # defaults.llm applies to all agents; per-agent llm.router overrides it. lm = state.get("local_models") or {} if lm.get("enabled"): config["defaults"]["llm"]["local"] = {"model": lm["local_model"], "timeout": 180} config["defaults"]["llm"]["router"] = { "enabled": True, "router_model": lm["router_model"], "local_model": lm["local_model"], "confidence": 0.75, } _write_yaml(overlay / "config" / "config.yaml", config, state) # --- team.json --- team = {"humans": state["team_members"], "agents": []} _write_json(overlay / "config" / "team.json", team, state) # --- mcp.yaml --- mcp_yaml_path = overlay / "config" / "mcp.yaml" if not mcp_yaml_path.exists(): mcp_config = { "servers": { "kbots-tools": { "transport": "stdio", "command": str(ENGINE_ROOT / ".venv" / "bin" / "python3"), "args": ["-m", "src.mcp_server"], "cwd": str(ENGINE_ROOT), } } } mcp_yaml_path.write_text(yaml.dump(mcp_config, default_flow_style=False, sort_keys=False)) ok(f"Created {_display(mcp_yaml_path)}") # --- agents.yaml + agent directory (shared scaffolding) --- # Full-control main agent → privileged (full shell); else coordinator. main_tier = "privileged" if agent.get("full_control") else "coordinator" written = scaffold_agent( overlay, agent["name"], agent["display_name"], agent["description"], model=agent["model"], tier=main_tier, personality=agent.get("personality", ""), routing=agent["routing"], bot_account=bot_name, engine_root=ENGINE_ROOT, kbots_modules=state.get("kbots_modules", ""), exist_ok=True, ) for p in written: ok(f"Created {_display(p, overlay)}") ok(f"Agent directory: {_display(agent_dir)}") # Neutralise Core remote — prevent accidental pushes to the upstream repo _neutralise_core_remote() def step_ops_instance(state: dict): header("Step 14: Privileged Ops Instance (optional)") info("kbots supports a dual-instance deployment pattern:") info(" Main instance — sandboxed, runs your user-facing agents") info(" Ops instance — unsandboxed, single privileged agent for dev/ops") info("") info("The ops agent can edit code, restart services, and run deploys.") info("Only the system owner should have access to it.") info("See ARCHITECTURE.md for full details.") print() info("Advanced & optional — if you're just getting started, choose No. " "You can add this later with scripts/settings.py.") if not ask_yn("Set up a privileged ops instance?", default=False): info("Skipped — you can set this up later via scripts/settings.py") return overlay: Path = state["overlay"] vault: FernetVault = state["vault"] display_name, agent_name = ask_display_name( "Ops agent name (shown in Discord — you'll @mention this)", "Engineer") description = ask("Description", "Privileged ops agent — unsandboxed, owner-only") model = ask_choice("Model", ["sonnet", "opus"], default="opus") # Bot account — same derived name; one agent, one bot, one name. bot_name = agent_name print() info("The ops agent needs its own Discord bot account.") token = ask_discord_token(f"Discord bot token for '{display_name}'") while token: info("Validating token...") bot_info, reason = validate_discord_token(token) if bot_info: ok(f"Bot verified: {bot_info.get('username', '?')}") break if reason == "network": if ask_yn("Couldn't reach Discord to validate. Store the token " "unverified?", default=False): break else: err("Discord rejected that token — re-copy it from the Developer " "Portal.") token = ask_discord_token(f"Discord bot token for '{display_name}'") if token: vault_key = f"discord-{bot_name}" vault.set(vault_key, token) ok(f"Token stored as '{vault_key}'") state.setdefault("extra_bots", {})[bot_name] = vault_key show_invite_link(state, bot_name, token, bot_info) else: warn("No token — add it later via vault-manage.py") # Routing routing = _ask_routing(bot_name, default_mentions=True) agent_dir = overlay / "agents" / agent_name # Add bot account to main config.yaml if token: config_path = overlay / "config" / "config.yaml" if config_path.exists(): with open(config_path) as f: cfg = yaml.safe_load(f) or {} dc = cfg.setdefault("connectors", {}).setdefault("discord", {}) # This bot has a valid token, so Discord can run even if the main # token was skipped earlier. dc["enabled"] = True dc.setdefault("accounts", {})[bot_name] = {"token_key": f"discord-{bot_name}"} # If the main account has no token in the vault, drop it so preflight # doesn't fail-closed on the missing secret. if state.get("discord_skip"): dc.get("accounts", {}).pop("main", None) config_path.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False)) ok(f"Added bot '{bot_name}' to config.yaml") # Scaffold the ops agent's identity from the platform-matching template: # Linux gets the separate unsandboxed rescue instance, macOS gets a # main-instance ops agent (no systemd sandbox to escape from). template_name = "ops-claude-macos.md" if sys.platform == "darwin" else "rescue-claude.md" template_path = ENGINE_ROOT / "config" / "templates" / template_name if template_path.exists(): claude_md = template_path.read_text().format( display_name=display_name, agent_dir=agent_dir, engine_root=ENGINE_ROOT) else: claude_md = f"# {display_name}\n\nYou are {display_name} — the ops and dev agent.\n" warn(f"Template not found at config/templates/{template_name} — using minimal fallback") # The separate "rescue" profile exists for Linux sandbox isolation (the # main service runs sandboxed; the ops agent needs its own unsandboxed # service). macOS/launchd has no sandbox, so a second service is pointless # and would leave the ops bot connected-but-unserved — put the ops agent in # the MAIN instance so the single service actually handles it. if sys.platform == "darwin": ops_agents_file, ops_profile = "agents.yaml", "" info("macOS: adding the ops agent to the main instance (no separate " "sandboxed service needed).") else: ops_agents_file, ops_profile = "agents.rescue.yaml", "rescue" written = scaffold_agent( overlay, agent_name, display_name, description, model=model, tier="privileged", routing=routing, bot_account=bot_name, engine_root=ENGINE_ROOT, kbots_modules=state.get("kbots_modules", ""), profile=ops_profile, agents_file=ops_agents_file, claude_md=claude_md, exist_ok=True, ) for p in written: ok(f"Created {_display(p, overlay)}") state["ops_profile"] = ops_profile print() ok(f"Ops instance configured — agent: {display_name}") if sys.platform == "darwin": info("The ops agent runs inside the main service — nothing more to install.") else: info("Its service unit (kbots-rescue.service, unsandboxed) is rendered " "and installed in the Systemd Services step.") def step_extras(state: dict): header("Step 15: Additional Setup (optional)") info("Further agents are created by your MAIN AGENT after setup:") info("ask it to create an agent, approve via HITL, then restart the service.") while True: print() print(" What would you like to add?") print(" 1) Another team member") print(" 2) Another Discord bot") print(" 3) Done — finish setup") print() choice = ask("Choice", "3") if choice in ("3", "done", "d", ""): break elif choice in ("1", "team"): _add_team_member(state) elif choice in ("2", "bot"): _add_bot(state) def service_account_home(unit_text: str) -> Path: """The home directory of the account the unit runs as. `%h` in a SYSTEM unit is the service manager's home, which is root's, not the `User=`'s. The template ships `Environment=HOME=%h` with a comment saying it is rewritten at install time, and nothing rewrote it: a fresh Debian install failed with `failed to create directory /root/.cache/uv: Permission denied` and exited 2 before doing anything. Worse than the crash is the near miss, since the same %h would send Claude Code to /root/.claude/.credentials.json, where an authenticated service account reads as unauthenticated. Resolved from the unit's own User= rather than from whoever runs setup, because setup is routinely run under sudo. """ import pwd for line in unit_text.splitlines(): if line.startswith("User="): user = line.split("=", 1)[1].strip() if user: try: return Path(pwd.getpwnam(user).pw_dir) except KeyError: # The account is created by install-systemd.sh, which may # not have run yet. Debian's adduser --system default. return Path("/home") / user return Path.home() def service_writable_dirs(overlay: Path) -> list[Path]: """Directories the unit grants ReadWritePaths and something must create. The overlay half is NOT a list maintained here. It comes from base.overlay_writable_dirs, which is also what overlay_state_path resolves against, so a state file the engine adds is writable by construction rather than by someone remembering to edit this function too. Keeping a second copy here is how the same EROFS bug shipped four times. /tmp and the home dotfile dirs are excluded: the first always exists, and the second belong to an account setup may not be able to write into. The shared-state directory is DERIVED from src.core.base, the same resolver every state module writes through, rather than restated here. Two hand-maintained lists that have to agree drifted four times, each one surfacing as an EROFS days later inside the service and never in a test. """ from src.core.base import overlay_writable_dirs return [ENGINE_ROOT / "data", *overlay_writable_dirs(overlay)] def render_service_unit(unit_text: str, overlay: Path, env_lines: list[str]) -> str: """Fill the template's install-time placeholders. ReadWritePaths gains tools/ and skills/: create_tool and create_skill write a .py or .yaml into them and tool_scope keeps its sidecar in tools/, so without them every agent-authored capability fails on Linux while working perfectly on the developer's Mac, which has no sandbox. """ home = service_account_home(unit_text) out = [] for line in unit_text.split("\n"): if line.startswith("Environment=HOME="): out.append(f"Environment=HOME={home}") elif line.startswith("Environment=PATH="): out.append(f"Environment=PATH={home}/.local/bin:/usr/local/bin:/usr/bin:/bin") out.extend(env_lines) elif line.startswith("ReadWritePaths="): paths = " ".join(str(d) for d in service_writable_dirs(overlay)) # ~/.claude.json lives at the root of a read-only home # (ProtectHome=read-only). Claude Code rewrites it ATOMICALLY to # mark a workspace trusted: mkstemp(~/.claude.json.XXXX.tmp) then # rename. Granting only the .claude directory, or even the file # itself by name, leaves the temp file's parent read-only, so the # write fails with EROFS, trust never persists, and every agent # gets "unapproved-permission" on every tool. Grant the home dir. out.append(f"ReadWritePaths={paths} /tmp {home}/.cache {home}/.claude {home}") elif line.startswith("ReadOnlyPaths="): out.append(f"ReadOnlyPaths={ENGINE_ROOT} {overlay}") else: out.append(line) return "\n".join(out) def build_service_unit(template: str, overlay: Path, env_lines: list[str], uv_path: str) -> str: """Template on disk -> the exact unit this install should be running. The whole transform, so the wizard and scripts/refresh_units.py cannot render it differently. They used to be one path, because only the wizard ever rendered a unit — and that was the bug: a unit fix could be committed, pulled, and still never reach a single running machine, because nothing short of re-running setup regenerated it. """ # Rewrite /opt/kbots only for non-default installs; vanilla paths pass through. # flags=re.M: without it $ only matches end-of-STRING, so a path that ends a # line mid-file (WorkingDirectory=/opt/kbots) is never rewritten. if str(ENGINE_ROOT) != "/opt/kbots": template = re.sub(r"/opt/kbots(?=/|$)", str(ENGINE_ROOT), template, flags=re.M) template = template.replace("ExecStart=/usr/local/bin/uv", f"ExecStart={uv_path}") return render_service_unit(template, overlay, env_lines) def build_timer_unit(template: str, overlay: Path) -> str: """Same, for the maintenance timer/service units under config/timers/.""" if str(ENGINE_ROOT) != "/opt/kbots": template = re.sub(r"/opt/kbots(?=/|$)", str(ENGINE_ROOT), template, flags=re.M) out = [] for line in template.split("\n"): out.append(line) if line.startswith("Environment=KBOTS_HOME="): out.append(f"Environment=KBOTS_OVERLAY={overlay}") return "\n".join(out) def build_launchd_plist(template: str, overlay: Path, uv_path: str, home: Path, path_env: str, modules: str = "") -> str: """Template on disk -> this install's launchd plist. See build_service_unit.""" extra_env = "" if modules: extra_env = (" KBOTS_MODULES\n" f" {modules}") content = (template .replace("__UV__", uv_path) .replace("__ENGINE_ROOT__", str(ENGINE_ROOT)) .replace("__PATH__", path_env) .replace("__HOME__", str(home)) .replace("__OVERLAY__", str(overlay))) if extra_env: return content.replace(" ", extra_env) return content.replace("\n ", "") def render_rescue_unit(template: str, overlay: Path, env_lines: list[str], uv_path: str) -> str: """Render the rescue (ops) instance unit the same way the main unit is. A raw copy of the template — which is what the wizard used to tell people to do — crash-loops on a real install: `Environment=HOME=%h` resolves to ROOT's home in a system unit, so the vault key file and Claude credentials are looked up in the wrong place, and nothing sets KBOTS_OVERLAY, so the instance can't find agents.rescue.yaml or the vault at all. render_service_unit fixes HOME/PATH and injects the env lines; the rescue template carries no ReadWritePaths/ReadOnlyPaths lines, so no sandbox is introduced — the whole point of the rescue instance is that it has none. """ if str(ENGINE_ROOT) != "/opt/kbots": # flags=re.M: without it $ only matches end-of-STRING, so a path that # ends a line mid-file (WorkingDirectory=/opt/kbots) is never rewritten. template = re.sub(r"/opt/kbots(?=/|$)", str(ENGINE_ROOT), template, flags=re.M) template = template.replace("ExecStart=/usr/local/bin/uv", f"ExecStart={uv_path}") return render_service_unit(template, overlay, env_lines) def build_health_config(state: dict) -> dict: """Everything scripts/health-audit.sh needs to audit THIS deployment. Built from wizard state rather than shipped as an example: the service list, vault keys and paths are per-deployment facts the wizard already holds, and a generic template would audit nothing real. """ overlay: Path = state["overlay"] services = ["kbots"] if state.get("ops_profile") == "rescue": services.append("kbots-rescue") vault_keys: list[str] = [] if not state.get("discord_skip"): vault_keys.append(state.get("bot_token_key", "discord-token")) vault_keys += list((state.get("extra_bots") or {}).values()) return { "deployment": "kbots", "paths": { "kbots_home": str(ENGINE_ROOT), "overlay": str(overlay), "vault": str(overlay / "config" / "secrets.enc"), "memory_db": str(ENGINE_ROOT / "data" / "memory.db"), "embeddings_model": str(ENGINE_ROOT / "data" / "models" / "bge-small-en-v1.5"), }, "thresholds": {"disk_percent": 80, "ram_percent": 85, "swap_percent": 70, "load_per_cpu": 1.5}, "services": services, "config_files": [str(overlay / "config" / "config.yaml"), str(overlay / "config" / "agents.yaml"), str(overlay / "config" / "team.json")], "vault_expected_keys": vault_keys, } def step_systemd(state: dict): header("Step 16: Systemd Services") if not shutil.which("systemctl"): info("systemd not available — skipping") return overlay: Path = state["overlay"] has_sudo = _check_sudo() if not has_sudo: warn("sudo not available — service files will be generated but not installed") info("You'll need to symlink them to /etc/systemd/system/ manually") print() # The nightly health audit (and the system_audit tool) refuse to run # without this file, and nothing ever shipped or generated one — so no # deployment had a working audit until someone authored it by hand. _write_yaml(overlay / "config" / "health-config.yaml", build_health_config(state), state) # --- Generate timer unit files (required for memory decay, health, integrity) --- timers_dir = ENGINE_ROOT / "config" / "timers" if timers_dir.exists(): info("Generating maintenance timer units...") for f in sorted(timers_dir.iterdir()): if not f.is_file(): continue # Templates carry literal /opt/kbots; rewrite only for non-default installs. # Keeping literals means vanilla Core installs with symlinked units # (the default from install-systemd.sh) survive `git pull` unscathed. out_path = overlay / "systemd" / f.name out_path.write_text(build_timer_unit(f.read_text(), overlay)) ok(f"Timer units written to {_display(overlay / 'systemd')}") # --- Generate main service unit file --- install_service = False print() if ask_yn("Install systemd service? (auto-start on boot)"): install_service = True uv_path = shutil.which("uv") or f"{Path.home()}/.local/bin/uv" template_path = ENGINE_ROOT / "config" / "kbots.service" if not template_path.exists(): err(f"Service template not found: {template_path}") return # Inject environment variables after the PATH line env_lines = [f"Environment=KBOTS_OVERLAY={overlay}"] if state.get("kbots_modules"): env_lines.append(f"Environment=KBOTS_MODULES={state['kbots_modules']}") service = build_service_unit( template_path.read_text(), overlay, env_lines, uv_path) # Every writable path must exist before the unit starts. systemd builds # the mount namespace BEFORE exec, so a ReadWritePaths entry that is # missing fails at step NAMESPACE with status=226 and a message naming # the path rather than the permission, and the service restart-loops # without ever reaching the code that would have created it. for d in service_writable_dirs(overlay): try: d.mkdir(parents=True, exist_ok=True) except OSError as e: warn(f"Could not create {d}: {e} — the service may fail at step NAMESPACE") out_path = overlay / "systemd" / "kbots.service" out_path.write_text(service) ok(f"Service file: {_display(out_path)}") # --- Rescue (ops) instance unit, if one was configured --- # Written into overlay/systemd BEFORE install-systemd.sh runs, so the # same pass symlinks and reloads it with everything else. if state.get("ops_profile") == "rescue": rescue_template = ENGINE_ROOT / "config" / "kbots-rescue.service" if rescue_template.exists(): rescue = render_rescue_unit( rescue_template.read_text(), overlay, env_lines, uv_path) rescue_out = overlay / "systemd" / "kbots-rescue.service" rescue_out.write_text(rescue) ok(f"Service file: {_display(rescue_out)}") else: warn(f"Rescue unit template not found: {rescue_template}") # --- Install into systemd (bash script handles symlinks, reload, enable) --- install_script = ENGINE_ROOT / "scripts" / "install-systemd.sh" if has_sudo and install_script.exists(): info("Installing units into systemd...") cmd = ["sudo", "-n", "bash", str(install_script), str(overlay)] if install_service: cmd.append("--enable-service") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: if result.stdout.strip(): for line in result.stdout.strip().split("\n"): info(line.strip()) undo_units = ["kbots.service"] if state.get("ops_profile") == "rescue": undo_units.append("kbots-rescue.service") def _undo_systemd(units=tuple(undo_units)): for unit in units: subprocess.run(["sudo", "-n", "systemctl", "disable", "--now", unit], capture_output=True) subprocess.run(["sudo", "-n", "rm", "-f", f"/etc/systemd/system/{unit}"], capture_output=True) subprocess.run(["sudo", "-n", "systemctl", "daemon-reload"], capture_output=True) _track_undo(state, "systemd service (stop + remove unit)", _undo_systemd) ok("Systemd units installed and timers enabled") if install_service: info("Starting kbots service...") start = subprocess.run( ["sudo", "-n", "systemctl", "restart", "kbots"], capture_output=True, text=True, ) if start.returncode == 0: ok("Service started") _verify_agent_online(state, "linux") else: err(f"Failed to start service: {start.stderr.strip()}") info("Start manually: sudo systemctl start kbots") # install-systemd.sh symlinks the rescue unit but only enables # timers and the main service — enable it here, or the ops # agent stays configured-but-never-served. if state.get("ops_profile") == "rescue": info("Starting rescue (ops) service...") rstart = subprocess.run( ["sudo", "-n", "systemctl", "enable", "--now", "kbots-rescue.service"], capture_output=True, text=True, ) if rstart.returncode == 0: ok("Rescue service started — the ops agent serves its own bot") info("Logs: journalctl -u kbots-rescue -f") else: err(f"Failed to start rescue service: {rstart.stderr.strip()}") info("Start manually: sudo systemctl enable --now kbots-rescue") else: err("Systemd installation failed:") if result.stderr.strip(): for line in result.stderr.strip().split("\n"): err(f" {line}") elif not has_sudo: warn("No sudo — unit files generated but not installed") info(f"Install manually: sudo bash {install_script} {overlay}") else: warn(f"Install script not found: {install_script}") def step_launchd(state: dict): """macOS parity for step_systemd: generate + install a launchd unit.""" header("Step 16: launchd Service (macOS)") overlay: Path = state["overlay"] # Timers are Linux-only, but the system_audit tool runs the same script # on demand — it needs this file on macOS too. _write_yaml(overlay / "config" / "health-config.yaml", build_health_config(state), state) template_path = ENGINE_ROOT / "config" / "kbots.launchd.plist" if not template_path.exists(): err(f"launchd template not found: {template_path}") return home = Path.home() uv_path = shutil.which("uv") or "/opt/homebrew/bin/uv" path_env = f"{home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" content = build_launchd_plist( template_path.read_text(), overlay, uv_path, home, path_env, state.get("kbots_modules") or "") (overlay / "data").mkdir(parents=True, exist_ok=True) out_path = overlay / "systemd" / "com.kbots.agent.plist" out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(content) ok(f"launchd unit: {_display(out_path, overlay)}") plist_dst = home / "Library" / "LaunchAgents" / "com.kbots.agent.plist" print() if not ask_yn("Install + start the service now? (auto-start on login)"): warn("Skipped — install manually:") info(f" cp {out_path} {plist_dst}") info(f" launchctl bootstrap gui/$(id -u) {plist_dst}") return plist_dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(out_path, plist_dst) uid = os.getuid() # Re-install cleanly if a previous version is loaded subprocess.run( ["launchctl", "bootout", f"gui/{uid}/com.kbots.agent"], capture_output=True, ) def _undo_launchd(dst=plist_dst, u=uid): subprocess.run(["launchctl", "bootout", f"gui/{u}/com.kbots.agent"], capture_output=True) dst.unlink(missing_ok=True) _track_undo(state, "launchd service (stop + remove plist)", _undo_launchd) result = subprocess.run( ["launchctl", "bootstrap", f"gui/{uid}", str(plist_dst)], capture_output=True, text=True, ) if result.returncode == 0: ok("Service installed and started") info("Stop: launchctl bootout gui/$(id -u)/com.kbots.agent") info(f"Start: launchctl bootstrap gui/$(id -u) {plist_dst}") info("Restart: launchctl kickstart -k gui/$(id -u)/com.kbots.agent") info(f"Logs: tail -f {overlay}/data/launchd.stderr.log") _verify_agent_online(state, "darwin") else: err(f"launchctl bootstrap failed: {result.stderr.strip()}") info(f"Install manually: launchctl bootstrap gui/$(id -u) {plist_dst}") def _verify_agent_online(state: dict, platform: str, timeout: int = 60): """Poll until the service is active and the Discord connector reports ready.""" import time if state.get("discord_skip"): warn("Discord not configured — skipping online verification.") info("Add a token via vault-manage.py, restart, and mention the bot to test.") return overlay: Path = state["overlay"] agent = state.get("agent", {}) display_name = agent.get("display_name", "your agent") info(f"Waiting for the main agent to come online (up to {timeout}s)...") def _logs() -> str: if platform == "darwin": log_path = overlay / "data" / "launchd.stderr.log" try: return log_path.read_text()[-20000:] except OSError: return "" result = subprocess.run( ["sudo", "-n", "journalctl", "-u", "kbots", "-n", "200", "--no-pager"], capture_output=True, text=True, ) return result.stdout if result.returncode == 0 else "" def _active() -> bool: if platform == "darwin": r = subprocess.run( ["launchctl", "print", f"gui/{os.getuid()}/com.kbots.agent"], capture_output=True, ) return r.returncode == 0 r = subprocess.run( ["systemctl", "is-active", "--quiet", "kbots"], capture_output=True ) return r.returncode == 0 start_time = time.monotonic() while time.monotonic() - start_time < timeout: if _active() and "ready as" in _logs(): print() ok(f"{BOLD}MAIN AGENT ONLINE{RESET} — mention @{display_name} in Discord to talk to it") return time.sleep(2) warn("Timed out waiting for the agent to report ready.") if platform == "darwin": info(f"Check logs: tail -50 {overlay}/data/launchd.stderr.log") else: info("Check logs: journalctl -u kbots -n 50") info("Likely causes: invalid Discord token, missing Message Content intent,") info("or Claude Code not authenticated (claude auth login).") def step_browser(state: dict): header("Step 17: Browser Tool (optional)") info("The browse_url tool uses a headless Chromium browser via Playwright") info("to fetch JavaScript-rendered pages. The Python package is already") info("installed; Chromium itself is a separate ~170MB download.") print() if not ask_yn("Install Chromium for the browse_url tool now?", default=True): warn("Skipped. browse_url will return an error until you run:") info(" uv run playwright install chromium") return cmd = ["uv", "run", "playwright", "install", "chromium"] try: print() info("Running: " + " ".join(cmd)) result = subprocess.run(cmd, check=False) if result.returncode == 0: ok("Chromium installed — browse_url tool is ready.") else: err("Chromium install failed (exit " + str(result.returncode) + ").") info("Run manually later: uv run playwright install chromium") except FileNotFoundError: err("'uv' not found on PATH.") info("Run manually later: uv run playwright install chromium") def step_final_sync(state: dict): """Re-run sync now that KBOTS_MODULES and KBOTS_OVERLAY are configured.""" kbots_modules = state.get("kbots_modules", "") overlay = state.get("overlay") if not overlay and not kbots_modules: return # nothing new to sync header("Final Dependency Sync") info("Re-syncing with module and overlay configuration...") env = {**os.environ} if kbots_modules: env["KBOTS_MODULES"] = kbots_modules if overlay: env["KBOTS_OVERLAY"] = str(overlay) sync_script = ENGINE_ROOT / "scripts" / "sync.sh" if sync_script.exists(): subprocess.run([str(sync_script)], cwd=str(ENGINE_ROOT), env=env, check=False) ok("Dependencies synced with module configuration") def step_summary(state: dict): header("Setup Complete") overlay: Path = state["overlay"] agent = state["agent"] vault: FernetVault = state["vault"] print(f" {BOLD}Core:{RESET} {ENGINE_ROOT}") print(f" {BOLD}Overlay:{RESET} {overlay}") print(f" {BOLD}Vault:{RESET} {len(vault.list_keys())} secret(s)") print(f" {BOLD}Team:{RESET} {len(state['team_members'])} member(s)") print(f" {BOLD}Agent:{RESET} {agent['display_name']} ({agent['model']})") if state.get("selected_modules"): print(f" {BOLD}Modules:{RESET} {', '.join(state['selected_modules'])}") if state.get("discord_skip"): print(f" {BOLD}Discord:{RESET} {YELLOW}not configured{RESET} — add token via vault-manage.py") else: print(f" {BOLD}Discord:{RESET} connected (guild: {state.get('guild_id', '?')})") if state.get("invite_urls"): print(f"\n {BOLD}Bot install links{RESET} (add a bot to a server any time):") for name, url in state["invite_urls"].items(): print(f" {name}: {CYAN}{url}{RESET}") if sys.platform == "darwin": restart_cmd = "launchctl kickstart -k gui/$(id -u)/com.kbots.agent" logs_cmd = f"tail -f {overlay}/data/launchd.stderr.log" timers_note = (f" {DIM}Maintenance timers are Linux-only; " f"run scripts/memory-decay.sh manually if needed.{RESET}\n") else: restart_cmd = "sudo systemctl restart kbots" logs_cmd = "journalctl -u kbots -f" timers_note = "" agent_dir = overlay / "agents" / agent["name"] print(f""" {BOLD}Generated files:{RESET} {overlay}/config/config.yaml {overlay}/config/agents.yaml {overlay}/config/team.json {agent_dir}/AGENTS.md (+ CLAUDE.md stub) {agent_dir}/.mcp.json {BOLD}Next steps:{RESET} 1. Review and customise {agent_dir}/AGENTS.md 2. Talk to your agent: mention @{agent['display_name']} in Discord 3. Logs: {CYAN}{logs_cmd}{RESET} 4. Manage secrets: {CYAN}uv run python vault-manage.py{RESET} 5. Settings: {CYAN}uv run python scripts/settings.py{RESET} {BOLD}Adding more agents:{RESET} Ask your main agent to create them (it has a create_agent tool). You approve via HITL in Discord, then restart: {CYAN}{restart_cmd}{RESET} {BOLD}Updating the install:{RESET} {CYAN}cd {ENGINE_ROOT} && scripts/update.sh{RESET} Pulls, syncs deps, hot-reloads tools/skills — restarts only when core code changed. {timers_note} {DIM}Config files are in the overlay — the engine stays clean for updates.{RESET} """) # ========================================================================== # Private helpers # ========================================================================== def _add_team_member(state: dict): overlay: Path = state["overlay"] name = ask("Name") role = ask("Role") discord_id = ask_id("Discord user ID") access = ask_choice("Access level", ["owner", "admin", "staff", "viewer"], default="staff") member = { "id": name.lower().replace(" ", "-"), "name": name, "type": "human", "access": access, "role": role, "responsibilities": [], "context": "", "contact": {"discord": discord_id}, "preferences": {"timezone": "UTC"}, } state["team_members"].append(member) # Update team.json team = {"humans": state["team_members"], "agents": []} team_path = overlay / "config" / "team.json" team_path.write_text(json.dumps(team, indent=2) + "\n") ok(f"Added {name} ({role}, {access})") def _add_bot(state: dict): overlay: Path = state["overlay"] vault: FernetVault = state["vault"] bot_name = ask("Bot account name (internal)") # Same validation loop as the main bot — a token stored unchecked here # would only surface as a connect failure at first boot. token = ask_discord_token("Bot token") while token: info("Validating token...") bot_info, reason = validate_discord_token(token) if bot_info: ok(f"Bot verified: {bot_info.get('username', '?')}") break if reason == "network": if ask_yn("Couldn't reach Discord to validate. Store the token " "unverified?", default=False): break else: err("Discord rejected that token — re-copy it from the Developer " "Portal.") token = ask_discord_token("Bot token") if not token: warn("Skipped — no token entered.") return vault_key = f"discord-{bot_name}" vault.set(vault_key, token) ok(f"Token stored as '{vault_key}'") show_invite_link(state, bot_name, token, bot_info) # Add to config.yaml. setdefault, not raw indexing: a config written by an # earlier version (or hand-edited) may lack the intermediate keys, and a # KeyError here used to abort the wizard and roll back the whole run. config_path = overlay / "config" / "config.yaml" with open(config_path) as f: cfg = yaml.safe_load(f) or {} discord_cfg = cfg.setdefault("connectors", {}).setdefault("discord", {}) discord_cfg.setdefault("accounts", {})[bot_name] = {"token_key": vault_key} config_path.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False)) ok(f"Bot '{bot_name}' added to config") info("Note: the bot serves nothing until an agent routes to it " "(agents.yaml → routing.discord.account).") # ========================================================================== # File writers # ========================================================================== def _ask_routing(bot_account: str, default_mentions: bool = True) -> dict: """Prompt for agent routing config and return the routing dict.""" mentions_only = ask_yn("Only respond when @mentioned?", default=default_mentions) print() info("Where should this agent listen? (most people: 1)") scope = ask_menu("Choose", [ "All channels — responds everywhere it can see (simplest)", "Specific channels only (by channel ID)", "A whole category (all channels in it)", "Specific channels + a category", ], default=1) channels = ask_ids("channel") if scope in (2, 4) else [] categories = ask_ids("category") if scope in (3, 4) else [] # routing.discord.guilds (a per-agent server allowlist) is deliberately NOT # asked here. The wizard serves single-server installs, where the answer is # always "no restriction" — and defaulting it to the install's guild would # be worse: the agent would silently ignore any server the bot is invited # to later. The router still honors the key for hand-edited agents.yaml. users = [] if ask_yn("Only respond to specific user(s)? (most people: No — team access " "tiers already gate who can talk to it)", default=False): users = ask_ids("user") routing = { "discord": { "account": bot_account, "channels": channels, "mentions": mentions_only, } } if categories: routing["discord"]["categories"] = categories if users: routing["discord"]["users"] = users return routing def _confirm_overwrite(path: Path) -> bool: """Ask before clobbering an existing config file. Keyed on the file's own existence — this used to be gated on whether the VAULT pre-existed, so re-running the wizard after the vault was deleted or moved silently overwrote config.yaml and team.json. """ if not path.exists(): return True return ask_yn(f" {_display(path)} exists. Overwrite?", default=False) def _write_config(path: Path, text: str): """config.yaml and team.json hold guild/admin IDs and real names — written owner-only, same as the vault files beside them.""" path.parent.mkdir(parents=True, exist_ok=True) write_private_file(path, text) def _write_yaml(path: Path, data: dict, state: dict): if not _confirm_overwrite(path): warn(f"Skipped {_display(path)}") return _write_config(path, yaml.dump(data, default_flow_style=False, sort_keys=False)) ok(f"Created {_display(path)}") def _write_json(path: Path, data: dict, state: dict): if not _confirm_overwrite(path): warn(f"Skipped {_display(path)}") return _write_config(path, json.dumps(data, indent=2) + "\n") ok(f"Created {_display(path)}") def _display(path: Path, overlay: Path | None = None) -> str: """Show path relative to ENGINE_ROOT or overlay for readability.""" if overlay: try: return f"/{path.relative_to(overlay)}" except ValueError: pass try: return str(path.relative_to(ENGINE_ROOT)) except ValueError: return str(path) def _neutralise_core_remote(): """Rename origin -> upstream and block push to prevent accidental pushes to the Core repo.""" try: result = subprocess.run( ["git", "-C", str(ENGINE_ROOT), "remote", "get-url", "origin"], capture_output=True, text=True, ) if result.returncode != 0: return # No origin remote subprocess.run( ["git", "-C", str(ENGINE_ROOT), "remote", "rename", "origin", "upstream"], capture_output=True, ) subprocess.run( ["git", "-C", str(ENGINE_ROOT), "remote", "set-url", "--push", "upstream", "no-push"], capture_output=True, ) ok("Core remote: origin → upstream (push disabled)") info("Pull updates: git pull upstream main") info("Maintainers: git remote rename upstream origin") except FileNotFoundError: pass # git not installed # ========================================================================== # Rollback — undo everything the wizard created if it's aborted # ========================================================================== def _track_path(state: dict, path: Path) -> None: """Mark a file/dir this run created for deletion on abort.""" state.setdefault("_created_paths", []).append(Path(path)) def _track_undo(state: dict, label: str, fn) -> None: """Register a non-file undo action (service stop, sudo revoke, profile edit).""" state.setdefault("_undo", []).append((label, fn)) def _strip_profile_block(profile_path: Path, block: str) -> None: if profile_path.exists(): content = profile_path.read_text() if block and block in content: profile_path.write_text(content.replace(block, "")) # Debian/Ubuntu ~/.bashrc returns early for non-interactive shells. Anything # exported below this guard does not exist in cron, in `ssh host ''`, or # in a script-driven `bash -c` — exactly the shells that run kbots tooling # unattended. The export has to go ABOVE it. _PROFILE_GUARDS = ( re.compile(r"^\s*case\s+\$-\s+in"), # case $- in *i*) ;; *) return;; esac re.compile(r"^\s*\[\s*-z\s+[\"']?\$PS1[\"']?\s*\]\s*&&\s*return"), # [ -z "$PS1" ] && return re.compile(r"^\s*\[\[\s*\$-\s*!=\s*\*i\*\s*\]\]\s*&&\s*return"), # [[ $- != *i* ]] && return ) def _guard_index(lines: list[str]) -> int | None: """Index of the interactive-shell guard, or None if the profile has none.""" for i, line in enumerate(lines): if any(g.match(line) for g in _PROFILE_GUARDS): return i return None def _write_profile_block(profile_path: Path, block: str) -> None: """Add `block` to the profile, above the interactive guard if there is one. The block is written verbatim so _strip_profile_block still matches it. """ content = profile_path.read_text() if profile_path.exists() else "" lines = content.splitlines(keepends=True) idx = _guard_index(lines) if idx is None: with open(profile_path, "a") as f: f.write(block) return lines.insert(idx, block.lstrip("\n")) profile_path.write_text("".join(lines)) def _relocate_profile_block(profile_path: Path) -> bool: """Move an already-installed kbots export block above the guard. Setup's idempotency check is "is KBOTS_OVERLAY in the file", so without this every host installed before the fix keeps its export stranded below the guard forever and re-running setup silently leaves it there. Returns True if anything moved. """ if not profile_path.exists(): return False lines = profile_path.read_text().splitlines(keepends=True) idx = _guard_index(lines) if idx is None: return False is_export = re.compile(r"^\s*export\s+KBOTS_(OVERLAY|MODULES)=").match moved = [line for line in lines[idx:] if is_export(line)] if not moved: return False kept = [line for i, line in enumerate(lines) if not (i >= idx and (is_export(line) or line.strip() == "# kbots environment"))] new_idx = _guard_index(kept) if new_idx is None: return False kept[new_idx:new_idx] = ["# kbots environment\n", *moved, "\n"] profile_path.write_text("".join(kept)) return True def _rollback(state: dict) -> None: """Delete everything this run created. Best-effort; each step is guarded.""" undos = state.get("_undo", []) paths = state.get("_created_paths", []) if not undos and not paths: info("Nothing was created yet — nothing to clean up.") return print() warn("Rolling back — removing everything this run created...") # Undo actions first (stop services, revoke sudo) before their files vanish. for label, fn in reversed(undos): try: fn() ok(f"Reverted: {label}") except Exception as e: err(f"Could not revert {label}: {e}") # Delete paths deepest-first so nested files go before their parents. for p in sorted(set(paths), key=lambda x: len(str(x)), reverse=True): try: if p.is_dir(): shutil.rmtree(p, ignore_errors=True) ok(f"Deleted: {p}") elif p.exists(): p.unlink() ok(f"Deleted: {p}") except Exception as e: err(f"Could not delete {p}: {e}") print() ok("Rollback complete — the system is back to how it was before setup.") def _check_sudo() -> bool: """Check if passwordless sudo is available.""" try: result = subprocess.run( ["sudo", "-n", "true"], capture_output=True, timeout=2, ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False # ========================================================================== # Main # ========================================================================== def main(): os.chdir(PROJECT_ROOT) banner() state: dict = {} steps = [ step_dependencies, step_install, step_overlay, step_hooks, step_modules, step_vault, step_discord, step_team, step_full_control, step_hitl, step_compression, step_local_models, step_training, step_pyextras, step_generate, step_ops_instance, step_extras, step_launchd if sys.platform == "darwin" else step_systemd, step_browser, step_final_sync, step_summary, ] _PROGRESS["total"] = len(steps) try: for step in steps: _PROGRESS["current"] += 1 step(state) except KeyboardInterrupt: print(f"\n\n {YELLOW}Setup interrupted.{RESET}") # An abort late in the run used to cost every answer given so far — # rollback was the only option. Keeping is safe: the wizard detects # existing files on a re-run (vault unlocks from the key file, configs # prompt before overwrite), so continuing is mostly pressing Enter. if sys.stdin.isatty() and (state.get("_undo") or state.get("_created_paths")): info("Keep what's been set up so far and re-run setup later to " "continue — or roll everything back now.") try: if ask_yn("Keep progress? (No = roll back)", default=True): ok("Kept. Continue any time with: uv run python setup.py") sys.exit(130) except (KeyboardInterrupt, EOFError): print() _rollback(state) sys.exit(130) except Exception as e: print(f"\n\n {RED}Setup failed: {e}{RESET}") _rollback(state) sys.exit(1) print(f" {GREEN}{BOLD}kbots is ready.{RESET}\n") if __name__ == "__main__": main()