#!/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()