Raja's Exocortex

Apex: An Agentic Coding Environment

Steps to setup a full agentic coding environment.

Features

  1. Sandboxed Proxmox LXC Ubuntu with AI agentes running with full permissions and root privilieges
  2. Base tools: node, npm, npx, uv, uvx
  3. Support tools: git, gh, tea, rg
  4. AI agents: claude code, codex, agy
  5. MCP: context7, jcodemunch
  6. Meta harness: omnigent (needs omnigent server setup)

Note: all tools run with npx, uvx and auto udpate on each run

Steps

  1. Setup a minimal Ubuntu Server LTS LXC in proxmox
  2. Run ubuntu-base and ubuntu-docker
  3. Add user apex and provide passwordless sudo access to this user
  4. Install below agent-harness-install.py in /usr/local/bin and run it
  5. Install below apex-runner systemd service

Filename: agent-harness-install.py

Change example.com to your domain where this file is hosted

#!/usr/bin/env python3
"""
Agent Installation and Maintenance Script for Linux.

This script automates the setup and update of development tools and AI agent wrappers:
1. Node.js: Fetches the latest LTS or Current version and installs it to a specified directory.
2. uv: Fetches and installs the latest version of the astral-sh/uv package manager.
3. gh: Fetches and installs the latest version of the GitHub CLI.
4. tea: Fetches and installs the latest version of the Gitea CLI.
5. Agent Wrappers: Installs shell script wrappers for AI agents into a binary directory:
   - codex: Wrapper for @openai/codex (bypasses approvals/sandbox)
   - gemini: Wrapper for @google/gemini-cli (enables yolo mode)
   - claude: Wrapper for @anthropic-ai/claude-code (skips permissions check)
   - bun: Wrapper for bun via npx
   - bunx: Wrapper for bun x via npx
   - omnigent: Wrapper for omnigent via uvx

Usage:
    sudo python3 agent-install.py [options]

Options:
    --node-latest   Install latest Node.js release instead of the default LTS.
    --install-dir   Directory to install Node.js (default: /opt/node).
    --symlink-dir   Directory for binary symlinks and scripts (default: /usr/local/bin).
    --skip-node     Skip Node.js and agent script installation.
    --skip-uv       Skip uv, uvx installation.
    --skip-gh       Skip GitHub CLI installation.
    --skip-tea      Skip Gitea CLI installation.
    --skip-bun      Skip bun wrapper installation.
    --skip-bunx     Skip bunx wrapper installation.
    --skip-omnigent Skip omnigent wrapper installation.
    --self-update   Update this installer script from the stable web URL and exit.
    --skip-self-update-check
                    Skip checking whether a newer installer script is available.

Prerequisites:
    - Linux operating system.
    - Write access to the symlink and installation directories (usually requires sudo).
"""

import argparse
import json
import logging
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path
from typing import Optional, List, Dict, Any

# --- Configuration & Constants ---
NODE_INDEX_URL = "https://nodejs.org/dist/index.tab"
DEFAULT_INSTALL_DIR = Path("/opt/node")
SYMLINK_DIR = Path("/usr/local/bin")
UV_REPO = "astral-sh/uv"
SELF_UPDATE_URL = "https://go.example.com/go/agent-harness-install.py"
HTTP_USER_AGENT = "agent-harness-install/1.0 (+https://go.example.com/go/agent-harness-install.py)"

CODEX_SCRIPT_CONTENT = """#!/bin/bash
# If omnigent is available, wrap codex with `omnigent codex`

# Omnigent is installed and we're not already running inside it,
# wrap codex inside omnigent
if command -v omnigent &>/dev/null && [ "${OMNIGENT}" != "1" ]; then
    exec omnigent codex "$@"
fi

# Run the latest codex using npx
exec npx -y @openai/codex@latest --dangerously-bypass-approvals-and-sandbox "$@"
"""

GEMINI_SCRIPT_CONTENT = """#!/bin/bash

exec npx -y @google/gemini-cli@latest --yolo "$@"
"""

CLAUDE_SCRIPT_CONTENT = """#!/bin/bash
# If omnigent is available, wrap claude with `omnigent claude`

# Omnigent is installed and we're not already running inside it,
# wrap claude inside omnigent
if command -v omnigent &>/dev/null && [ "${OMNIGENT}" != "1" ]; then
    exec omnigent claude "$@"
fi

# Run the latest claude using npx
exec npx -y @anthropic-ai/claude-code@latest --dangerously-skip-permissions "$@"
"""

BUN_SCRIPT_CONTENT = """#!/bin/bash

exec npx -y bun@latest "$@"
"""

BUNX_SCRIPT_CONTENT = """#!/bin/bash

exec npx -y bun@latest x "$@"
"""

AGY_SCRIPT_CONTENT = """#!/bin/bash
# If omnigent is available, wrap agy with `omnigent agy`

# Omnigent is installed and we're not already running inside it,
# wrap agy inside omnigent
if command -v omnigent &>/dev/null && [ "${OMNIGENT}" != "1" ]; then
    exec omnigent antigravity "$@"
fi

# Run agy.bin directly
exec agy.bin --dangerously-skip-permissions "$@"
"""

OMNIGENT_SCRIPT_CONTENT = """#!/bin/bash

exec uvx omnigent "$@"
"""

JCODEMUNCH_MCP_SCRIPT_CONTENT = """#!/bin/bash

# For times when jcodemuch is delisted from pip and needs direct git install
#exec uvx --from git+https://github.com/jgravelle/jcodemunch-mcp.git jcodemunch-mcp "$@"

exec uvx jcodemunch-mcp "$@"
"""

CONTEXT7_MCP_SCRIPT_CONTENT = """#!/bin/bash

exec npx -y @upstash/context7-mcp "$@"
"""

# Setup logging
logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)


class InstallationError(Exception):
    """Base class for installation errors."""
    pass


class SystemUtils:
    """System-level utility functions."""

    @staticmethod
    def get_arch_map() -> Dict[str, str]:
        return {
            "x86_64": "x64",
            "amd64": "x64",
            "aarch64": "arm64",
            "arm64": "arm64",
            "armv7l": "armv7l",
        }

    @classmethod
    def get_node_arch(cls) -> str:
        machine = platform.machine().lower()
        return cls.get_arch_map().get(machine, machine)

    @staticmethod
    def get_uv_arch() -> Optional[str]:
        machine = platform.machine().lower()
        if machine in ("x86_64", "amd64"):
            return "x86_64-unknown-linux-musl"
        elif machine in ("aarch64", "arm64"):
            return "aarch64-unknown-linux-musl"
        elif machine == "armv7l":
            return "armv7-unknown-linux-musleabihf"
        return None

    @staticmethod
    def check_permissions(path: Path) -> bool:
        """Check if the current user has write access to the path."""
        if os.getuid() == 0:
            return True
        return os.access(path, os.W_OK)


class Downloader:
    """Handles network requests and downloads."""

    @staticmethod
    def fetch_text(url: str) -> str:
        try:
            req = urllib.request.Request(url, headers={"User-Agent": HTTP_USER_AGENT})
            with urllib.request.urlopen(req) as response:
                return response.read().decode('utf-8')
        except Exception as e:
            raise InstallationError(f"Failed to fetch data from {url}: {e}")

    @staticmethod
    def download_file(url: str, dest: Path):
        logger.info(f"Downloading {url}...")
        try:
            req = urllib.request.Request(url, headers={"User-Agent": HTTP_USER_AGENT})
            with urllib.request.urlopen(req) as response, dest.open("wb") as output:
                shutil.copyfileobj(response, output)
        except Exception as e:
            raise InstallationError(f"Download failed for {url}: {e}")


class SelfUpdater:
    """Checks for and applies updates to this installer script."""

    def __init__(self, url: str):
        self.url = url
        self.script_path = Path(__file__).resolve()

    def fetch_latest(self) -> bytes:
        return Downloader.fetch_text(self.url).encode("utf-8")

    def is_update_available(self) -> bool:
        if not self.script_path.exists():
            logger.warning(f"Cannot check self-update; script not found: {self.script_path}")
            return False

        try:
            latest = self.fetch_latest()
            current = self.script_path.read_bytes()
        except InstallationError as e:
            logger.warning(str(e))
            return False
        except OSError as e:
            logger.warning(f"Cannot read current installer script: {e}")
            return False

        return latest != current

    def notify_if_update_available(self):
        if not self.is_update_available():
            return

        quoted_script = shlex.quote(str(self.script_path))
        logger.warning("A newer version of this installer is available.")
        logger.warning(f"Run this to update the installer script: sudo python3 {quoted_script} --self-update")
        logger.warning("Then rerun your original install/update command.")

    def update(self):
        if not self.script_path.exists():
            raise InstallationError(f"Cannot self-update; script not found: {self.script_path}")

        latest = self.fetch_latest()
        try:
            latest_text = latest.decode("utf-8")
            compile(latest_text, str(self.script_path), "exec")
        except Exception as e:
            raise InstallationError(f"Downloaded installer is not valid Python: {e}")

        current = self.script_path.read_bytes()
        if latest == current:
            logger.info("Installer script is already up to date.")
            return

        script_stat = self.script_path.stat()
        try:
            with tempfile.NamedTemporaryFile(
                mode="wb",
                delete=False,
                dir=str(self.script_path.parent),
                prefix=f".{self.script_path.name}.",
            ) as tmp_file:
                tmp_file.write(latest)
                tmp_path = Path(tmp_file.name)

            tmp_path.chmod(script_stat.st_mode)
            if hasattr(os, "chown") and os.getuid() == 0:
                os.chown(tmp_path, script_stat.st_uid, script_stat.st_gid)
            os.replace(tmp_path, self.script_path)
        except PermissionError as e:
            raise InstallationError(
                f"Permission denied updating {self.script_path}. Run with sudo: "
                f"sudo python3 {shlex.quote(str(self.script_path))} --self-update"
            ) from e
        except OSError as e:
            raise InstallationError(f"Failed to update {self.script_path}: {e}") from e
        finally:
            if "tmp_path" in locals() and tmp_path.exists():
                tmp_path.unlink()

        logger.info(f"Installer script updated: {self.script_path}")


class NodeManager:
    """Manages Node.js installation and updates."""

    def __init__(self, install_dir: Path, symlink_dir: Path):
        self.install_dir = install_dir
        self.symlink_dir = symlink_dir
        self.arch = SystemUtils.get_node_arch()

    def get_target_version(self, lts_only: bool = True) -> Optional[str]:
        lines = Downloader.fetch_text(NODE_INDEX_URL).splitlines()
        if not lines:
            return None

        header = lines[0].split('\t')
        try:
            lts_col = header.index('lts')
            ver_col = header.index('version')
        except ValueError:
            raise InstallationError("Unexpected Node.js index.tab format")

        for line in lines[1:]:
            parts = line.split('\t')
            if len(parts) <= max(lts_col, ver_col):
                continue
            version = parts[ver_col].strip()
            lts_val = parts[lts_col].strip().lower()
            is_lts = lts_val not in ('false', '', '-')
            if lts_only and not is_lts:
                continue
            return version
        return None

    def get_current_version(self) -> str:
        node_path = self.install_dir / "bin" / "node"
        if node_path.exists():
            try:
                return subprocess.check_output([str(node_path), "-v"], stderr=subprocess.DEVNULL).decode().strip()
            except (subprocess.CalledProcessError, OSError):
                return "none"
        return "none"

    def install(self, version: str):
        logger.info(f"Updating Node.js to {version}...")
        arch_str = f"linux-{self.arch}"
        ext = "tar.xz"
        url = f"https://nodejs.org/dist/{version}/node-{version}-{arch_str}.{ext}"

        with tempfile.TemporaryDirectory() as tmp_dir_str:
            tmp_dir = Path(tmp_dir_str)
            archive_path = tmp_dir / f"node.{ext}"

            Downloader.download_file(url, archive_path)

            logger.info("Extracting Node.js archive...")
            try:
                with tarfile.open(archive_path, "r:xz") as tar:
                    extract_kwargs: Dict[str, Any] = {"path": tmp_dir}
                    if sys.version_info >= (3, 12):
                        extract_kwargs["filter"] = "data"
                    tar.extractall(**extract_kwargs)
            except Exception as e:
                raise InstallationError(f"Extraction failed: {e}")

            extracted_folder = tmp_dir / f"node-{version}-{arch_str}"
            self.install_dir.mkdir(parents=True, exist_ok=True)

            logger.info(f"Syncing Node.js files to {self.install_dir}...")
            # Clean up existing installation
            for item in self.install_dir.iterdir():
                if item.is_dir():
                    shutil.rmtree(item)
                else:
                    item.unlink()

            # Copy new files
            for item in extracted_folder.iterdir():
                dest = self.install_dir / item.name
                if item.is_symlink():
                    dest.symlink_to(os.readlink(str(item)))
                elif item.is_dir():
                    shutil.copytree(item, dest, symlinks=True)
                else:
                    shutil.copy2(item, dest)

    def create_symlinks(self):
        for binary in ("node", "npm", "npx"):
            src = self.install_dir / "bin" / binary
            dst = self.symlink_dir / binary

            if not src.exists():
                logger.warning(f"Source binary not found: {src}")
                continue

            # Use relative path for symlink if possible
            try:
                rel_src = os.path.relpath(src, dst.parent)
            except ValueError:
                rel_src = str(src)

            if os.path.lexists(str(dst)):
                if dst.is_symlink() and os.readlink(str(dst)) == rel_src:
                    logger.info(f"Symlink OK: {dst} -> {rel_src}")
                    continue
                dst.unlink()

            try:
                dst.symlink_to(rel_src)
                logger.info(f"Symlink created: {dst} -> {rel_src}")
            except OSError as e:
                logger.error(f"Error creating symlink for {binary}: {e}")


class UVManager:
    """Manages uv installation and updates."""

    def __init__(self, symlink_dir: Path):
        self.symlink_dir = symlink_dir
        self.arch = SystemUtils.get_uv_arch()

    def get_latest_version(self) -> Optional[str]:
        api_url = f"https://api.github.com/repos/{UV_REPO}/releases/latest"
        try:
            req = urllib.request.Request(api_url)
            with urllib.request.urlopen(req) as response:
                data = json.loads(response.read().decode())
                return data["tag_name"].lstrip('v')
        except Exception as e:
            logger.warning(f"GitHub API fetch failed for uv: {e}. Trying fallback.")
            try:
                with urllib.request.urlopen(f"https://github.com/{UV_REPO}/releases/latest") as resp:
                    return resp.geturl().split('/')[-1].lstrip('v')
            except Exception:
                return None

    def get_current_version(self) -> str:
        binary_path = self.symlink_dir / "uv"
        if not binary_path.exists():
            return "none"
        try:
            output = subprocess.check_output([str(binary_path), "--version"], stderr=subprocess.STDOUT).decode().strip()
            return output.split()[1]
        except (subprocess.CalledProcessError, OSError, IndexError):
            return "none"

    def install(self, version: str):
        if not self.arch:
            raise InstallationError(f"Unsupported architecture for uv: {platform.machine()}")

        logger.info(f"Updating uv to {version}...")
        # uv download URL can vary, but usually it matches the tag name.
        # If the version doesn't start with 'v', we might need to add it,
        # but for recent uv releases the tag name itself is used in the URL.
        # Since get_latest_version lstrips 'v', and some releases use 'v' tags,
        # we'll try to be smart about it.
        url = f"https://github.com/{UV_REPO}/releases/download/{version}/uv-{self.arch}.tar.gz"

        with tempfile.TemporaryDirectory() as tmp_dir_str:
            tmp_dir = Path(tmp_dir_str)
            archive_path = tmp_dir / "uv.tar.gz"
            Downloader.download_file(url, archive_path)

            logger.info("Extracting uv archive...")
            try:
                with tarfile.open(archive_path, "r:gz") as tar:
                    tar.extractall(path=tmp_dir)
            except Exception as e:
                raise InstallationError(f"Extraction failed: {e}")

            extracted_dir = tmp_dir / f"uv-{self.arch}"
            if not extracted_dir.exists():
                # Fallback: search for uv binary
                for root, _, files in os.walk(tmp_dir):
                    if "uv" in files:
                        extracted_dir = Path(root)
                        break

            for binary in ("uv", "uvx"):
                src = extracted_dir / binary
                dst = self.symlink_dir / binary
                if src.exists():
                    logger.info(f"Installing {binary} to {self.symlink_dir}...")
                    if dst.exists():
                        dst.unlink()
                    shutil.copy2(src, dst)
                    dst.chmod(0o755)
                else:
                    logger.warning(f"Binary {binary} not found in archive.")


class GHManager:
    """Manages GitHub CLI installation and updates."""

    def __init__(self, symlink_dir: Path):
        self.symlink_dir = symlink_dir
        self.arch = SystemUtils.get_node_arch() # x64, arm64, etc.
        # Map to GH arch names
        self.gh_arch = {
            "x64": "amd64",
            "arm64": "arm64",
            "armv7l": "armv6",
        }.get(self.arch, self.arch)

    def get_latest_version(self) -> Optional[str]:
        api_url = "https://api.github.com/repos/cli/cli/releases/latest"
        try:
            req = urllib.request.Request(api_url)
            # GitHub API requires User-Agent
            req.add_header("User-Agent", "Mozilla/5.0")
            with urllib.request.urlopen(req) as response:
                data = json.loads(response.read().decode())
                return data["tag_name"].lstrip('v')
        except Exception as e:
            logger.warning(f"GitHub API fetch failed for gh: {e}")
            return None

    def get_current_version(self) -> str:
        binary_path = self.symlink_dir / "gh"
        if not binary_path.exists():
            return "none"
        try:
            output = subprocess.check_output([str(binary_path), "--version"], stderr=subprocess.STDOUT).decode().strip()
            # gh version 2.47.0 (2024-04-03)
            # Find first word that starts with digit and has a dot
            match = re.search(r"version\s+v?(\d+\.\d+\.\d+)", output, re.IGNORECASE)
            if match:
                return match.group(1)
            return "none"
        except (subprocess.CalledProcessError, OSError):
            return "none"

    def install(self, version: str):
        logger.info(f"Updating gh to {version}...")
        url = f"https://github.com/cli/cli/releases/download/v{version}/gh_{version}_linux_{self.gh_arch}.tar.gz"

        with tempfile.TemporaryDirectory() as tmp_dir_str:
            tmp_dir = Path(tmp_dir_str)
            archive_path = tmp_dir / "gh.tar.gz"
            Downloader.download_file(url, archive_path)

            logger.info("Extracting gh archive...")
            try:
                with tarfile.open(archive_path, "r:gz") as tar:
                    tar.extractall(path=tmp_dir)
            except Exception as e:
                raise InstallationError(f"Extraction failed: {e}")

            # gh extracts to gh_{version}_linux_{gh_arch}/bin/gh
            extracted_dir = tmp_dir / f"gh_{version}_linux_{self.gh_arch}"
            src = extracted_dir / "bin" / "gh"
            if not src.exists():
                # Fallback: find gh binary
                for root, _, files in os.walk(tmp_dir):
                    if "gh" in files and "bin" in root:
                        src = Path(root) / "gh"
                        break

            dst = self.symlink_dir / "gh"
            if src.exists():
                logger.info(f"Installing gh to {self.symlink_dir}...")
                if dst.exists():
                    dst.unlink()
                shutil.copy2(src, dst)
                dst.chmod(0o755)
            else:
                raise InstallationError("gh binary not found in archive.")


class TeaManager:
    """Manages Gitea CLI (tea) installation and updates."""

    def __init__(self, symlink_dir: Path):
        self.symlink_dir = symlink_dir
        self.arch = SystemUtils.get_node_arch()
        # Map to tea arch names
        self.tea_arch = {
            "x64": "amd64",
            "arm64": "arm64",
            "armv7l": "arm-7",
        }.get(self.arch, self.arch)

    def get_latest_version(self) -> Optional[str]:
        api_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/latest"
        try:
            with urllib.request.urlopen(api_url) as response:
                data = json.loads(response.read().decode())
                return data["tag_name"].lstrip('v')
        except Exception as e:
            logger.warning(f"Gitea API fetch failed for tea: {e}")
            return None

    def get_current_version(self) -> str:
        binary_path = self.symlink_dir / "tea"
        if not binary_path.exists():
            return "none"
        try:
            # Version: 0.14.0 golang: 1.26.2  go-sdk: v0.24.1
            output = subprocess.check_output([str(binary_path), "--version"], stderr=subprocess.STDOUT).decode().strip()
            # The output may contain ANSI color codes (e.g., ^[[1m0.14.0^[[0m)
            # and may be separated by tabs or spaces.
            parts = output.split()
            if len(parts) > 1 and "version" in parts[0].lower():
                # Pick the second part (the version) and strip anything that's not a digit or dot
                match = re.search(r"(\d+\.\d+\.\d+)", parts[1])
                if match:
                    return match.group(1)
            return "none"
        except (subprocess.CalledProcessError, OSError):
            return "none"

    def install(self, version: str):
        logger.info(f"Updating tea to {version}...")
        # tea-0.14.0-linux-amd64
        url = f"https://gitea.com/gitea/tea/releases/download/v{version}/tea-{version}-linux-{self.tea_arch}"

        dst = self.symlink_dir / "tea"
        with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
            tmp_path = Path(tmp_file.name)
            try:
                Downloader.download_file(url, tmp_path)
                logger.info(f"Installing tea to {self.symlink_dir}...")
                if dst.exists():
                    dst.unlink()
                shutil.copy2(tmp_path, dst)
                dst.chmod(0o755)
            finally:
                if tmp_path.exists():
                    tmp_path.unlink()


class AgyManager:
    """Manages antigravity-cli (agy) installation and updates."""

    INSTALL_SCRIPT_URL = "https://antigravity.google/cli/install.sh"

    def __init__(self, install_dir: Path):
        self.install_dir = install_dir

    def is_installed(self) -> bool:
        return (self.install_dir / "agy.bin").exists()

    def cleanup_old_backups(self):
        for f in self.install_dir.glob("agy.bin.*.old"):
            try:
                f.unlink()
                logger.info(f"Deleted agy backup: {f}")
            except OSError as e:
                logger.warning(f"Failed to delete agy backup {f}: {e}")

    def update(self):
        logger.info("agy already installed. Running 'agy.bin update'...")
        try:
            subprocess.check_call([str(self.install_dir / "agy.bin"), "update"])
        except subprocess.CalledProcessError as e:
            raise InstallationError(f"'agy.bin update' failed: {e}")
        self.cleanup_old_backups()

    def install(self):
        logger.info("Installing antigravity-cli...")
        with tempfile.TemporaryDirectory() as tmp_dir_str:
            script_path = Path(tmp_dir_str) / "install.sh"
            Downloader.download_file(self.INSTALL_SCRIPT_URL, script_path)
            script_path.chmod(0o755)
            try:
                # The antigravity install.sh takes a target *directory* via
                # --dir and writes "<dir>/agy". It ignores env vars and
                # otherwise defaults to "$HOME/.local/bin" (i.e. /root/.local/bin
                # under sudo), so pass --dir explicitly to land in install_dir.
                subprocess.check_call(
                    ["bash", str(script_path), "--dir", str(self.install_dir)],
                )
            except subprocess.CalledProcessError as e:
                raise InstallationError(f"antigravity-cli install script failed: {e}")
        # Rename agy -> agy.bin so the user's /usr/local/bin/agy wrapper is not clobbered.
        agy_plain = self.install_dir / "agy"
        agy_bin = self.install_dir / "agy.bin"
        if agy_plain.exists() and not agy_plain.is_symlink():
            if agy_bin.exists():
                agy_bin.unlink()
            agy_plain.rename(agy_bin)
            logger.info(f"Renamed {agy_plain} -> {agy_bin}")
        elif not agy_bin.exists():
            raise InstallationError("antigravity-cli install did not produce agy or agy.bin")
        logger.info("antigravity-cli installed.")

    def install_or_update(self):
        if self.is_installed():
            self.update()
        else:
            self.install()
            self.cleanup_old_backups()


class ScriptManager:
    """Manages shell script installation."""

    def __init__(self, name: str, content: str, symlink_dir: Path):
        self.name = name
        self.content = content
        self.dest_path = symlink_dir / name

    def install(self):
        logger.info(f"Checking {self.name} script at {self.dest_path}...")

        needs_write = True
        if self.dest_path.exists():
            try:
                current_content = self.dest_path.read_text()
                if current_content == self.content:
                    logger.info(f"{self.name} script is already up to date.")
                    needs_write = False
                else:
                    logger.info(f"{self.name} script is different. Overwriting...")
            except Exception as e:
                logger.warning(f"Failed to read existing {self.name} script: {e}. Overwriting...")

        if needs_write:
            logger.info(f"Writing {self.name} script to {self.dest_path}...")
            try:
                self.dest_path.write_text(self.content)
                self.dest_path.chmod(0o755)
                logger.info(f"{self.name} script installed successfully.")
            except Exception as e:
                raise InstallationError(f"Failed to install {self.name} script: {e}")


def main():
    if platform.system().lower() != "linux":
        logger.error("This script is only supported on Linux.")
        sys.exit(1)

    parser = argparse.ArgumentParser(description="Install or update Node.js and uv on Linux")
    parser.add_argument("--node-latest", action="store_true", help="Install latest Node.js release")
    parser.add_argument("--node-lts", action="store_true", default=True, help="Install latest Node.js LTS (default)")
    parser.add_argument("--install-dir", type=Path, default=DEFAULT_INSTALL_DIR, help="Node.js install directory")
    parser.add_argument("--symlink-dir", type=Path, default=SYMLINK_DIR, help="Binary symlink directory")
    parser.add_argument("--skip-node", action="store_true", help="Skip Node.js installation")
    parser.add_argument("--skip-uv", action="store_true", help="Skip uv installation")
    parser.add_argument("--skip-gh", action="store_true", help="Skip GitHub CLI installation")
    parser.add_argument("--skip-tea", action="store_true", help="Skip Gitea CLI installation")
    parser.add_argument("--skip-bun", action="store_true", help="Skip bun wrapper installation")
    parser.add_argument("--skip-bunx", action="store_true", help="Skip bunx wrapper installation")
    parser.add_argument("--install-gemini", action="store_true", help="Install gemini wrapper (deprecated, opt-in only)")
    parser.add_argument("--skip-omnigent", action="store_true", help="Skip omnigent wrapper installation")
    parser.add_argument("--skip-agy", action="store_true", help="Skip antigravity-cli installation")
    parser.add_argument("--skip-jcodemunch-mcp", action="store_true", help="Skip jcodemunch-mcp wrapper installation")
    parser.add_argument("--skip-context7-mcp", action="store_true", help="Skip context7-mcp wrapper installation")
    parser.add_argument("-U", "--self-update", action="store_true", help="Update this installer script and exit")
    parser.add_argument(
        "--skip-self-update-check",
        action="store_true",
        help="Skip checking whether a newer installer script is available",
    )
    args = parser.parse_args()

    self_updater = SelfUpdater(SELF_UPDATE_URL)
    if args.self_update:
        try:
            self_updater.update()
        except InstallationError as e:
            logger.error(str(e))
            sys.exit(1)
        return

    if not args.skip_self_update_check:
        self_updater.notify_if_update_available()

    # Permission check
    if not SystemUtils.check_permissions(args.symlink_dir):
        logger.error(f"Need 'sudo' or write access to {args.symlink_dir}")
        sys.exit(1)

    try:
        # 1. Node.js
        if not args.skip_node:
            node = NodeManager(args.install_dir, args.symlink_dir)
            target_node = node.get_target_version(lts_only=not args.node_latest)
            current_node = node.get_current_version()

            logger.info(f"Node.js - Current: {current_node}, Target: {target_node}")
            if target_node and current_node != target_node:
                node.install(target_node)
                node.create_symlinks()
            else:
                logger.info("Node.js is already up to date.")
                node.create_symlinks()

            # 1.5 Shell scripts
            ScriptManager("codex", CODEX_SCRIPT_CONTENT, args.symlink_dir).install()
            if args.install_gemini:
                ScriptManager("gemini", GEMINI_SCRIPT_CONTENT, args.symlink_dir).install()
            ScriptManager("claude", CLAUDE_SCRIPT_CONTENT, args.symlink_dir).install()
            if not args.skip_bun:
                ScriptManager("bun", BUN_SCRIPT_CONTENT, args.symlink_dir).install()
            if not args.skip_bunx:
                ScriptManager("bunx", BUNX_SCRIPT_CONTENT, args.symlink_dir).install()

        # 2. uv
        if not args.skip_uv:
            uv = UVManager(args.symlink_dir)
            target_uv = uv.get_latest_version()
            current_uv = uv.get_current_version()

            logger.info(f"uv - Current: {current_uv}, Target: {target_uv}")
            if target_uv and current_uv != target_uv:
                uv.install(target_uv)
            else:
                logger.info("uv is already up to date.")
            if not args.skip_omnigent:
                ScriptManager("omnigent", OMNIGENT_SCRIPT_CONTENT, args.symlink_dir).install()

        # 3. gh
        if not args.skip_gh:
            gh = GHManager(args.symlink_dir)
            target_gh = gh.get_latest_version()
            current_gh = gh.get_current_version()

            logger.info(f"gh - Current: {current_gh}, Target: {target_gh}")
            if target_gh and current_gh != target_gh:
                gh.install(target_gh)
            else:
                logger.info("gh is already up to date.")

        # 4. tea
        if not args.skip_tea:
            tea = TeaManager(args.symlink_dir)
            target_tea = tea.get_latest_version()
            current_tea = tea.get_current_version()

            logger.info(f"tea - Current: {current_tea}, Target: {target_tea}")
            if target_tea and current_tea != target_tea:
                tea.install(target_tea)
            else:
                logger.info("tea is already up to date.")

        # 5. agy (antigravity-cli)
        if not args.skip_agy:
            AgyManager(args.symlink_dir).install_or_update()
            ScriptManager("agy", AGY_SCRIPT_CONTENT, args.symlink_dir).install()

        # 6. MCP servers
        if not args.skip_jcodemunch_mcp:
            ScriptManager("jcodemunch-mcp", JCODEMUNCH_MCP_SCRIPT_CONTENT, args.symlink_dir).install()
        if not args.skip_context7_mcp:
            ScriptManager("context7-mcp", CONTEXT7_MCP_SCRIPT_CONTENT, args.symlink_dir).install()

        logger.info("Installation/Update process complete.")

    except InstallationError as e:
        logger.error(str(e))
        sys.exit(1)
    except Exception as e:
        logger.exception(f"An unexpected error occurred: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

Filename: apex-runner.service

# Filename: /etc/systemd/system/apex-runner.service
#
# Copy this file to /etc/systemd/system/apex-runner.service and run:
#  omnigent login https://apex.example.com
#  systemctl daemon-reload; systemctl enable --now apex-runner
#
# Note:
#  1. Change apex.example.com
#  2. Change example_user

[Unit]
Description=Omnigent Runner
Documentation=https://omnigent.io
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple

# Run as non-root
User=example_user
Group=example_user
WorkingDirectory=/home/example_user

# Ensure the binary can be found
Environment="PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin"

# Login command
ExecStart=/usr/local/bin/omnigent host https://apex.example.com

# Restart policy
Restart=on-failure
RestartSec=15s

# Graceful shutdown
TimeoutStartSec=60
TimeoutStopSec=30
KillMode=process

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=apex-runner

[Install]
WantedBy=multi-user.target