import typer
import os
import subprocess
import shlex
import yaml
import json
import hashlib
import re
import shutil
import sys
import questionary
import datetime
import uuid
import time
import tempfile
from pathlib import Path
from typing import List, Optional, Dict, Any, Tuple
from contextlib import contextmanager
from rich.console import Console
from rich.rule import Rule
from rich.panel import Panel
from rich.table import Table
from rich.align import Align
from rich.text import Text
from rich.markdown import Markdown
from dotenv import load_dotenv

from armorer.profiles import RiskProfile, FilesystemPolicy, NetworkPolicy
from armorer.registry import AppRegistry, AppMetadata
from armorer.config import ConfigManager, resolve_armorer_config_dir
from armorer.agent import BaseAgent
from armorer.knowledge.hints import load_agent_hints
from armorer.catalog import resolve_playbook
from armorer.openclaw_config import resolve_openclaw_registered_install_path
from armorer.messaging import InboxOutboxRelay, SignalCoreBridge, TelegramCoreBridge, create_messaging_gateway
from armorer.messaging.telegram import TelegramGateway
from armorer.messaging.bridge import (
    _sanitize_bridge_user_text,
    build_local_cli_task_metadata,
    format_queue_cleanup_note,
)
from armorer.cli import app_configure as app_configure_cli
from armorer.cli import telegram_admin as telegram_admin_cli
from armorer.apps.openclaw import (
    OpenClawInstanceConfig,
    approve_openclaw_pairings,
    bind_openclaw_channels,
    check_openclaw_skill,
    discover_openclaw_pairing_requests,
    discover_openclaw_skills,
    load_openclaw_instance_config,
    verify_openclaw_channels,
)
from armorer.apps.openclaw import discovery as openclaw_discovery
from armorer.apps.openclaw import registry_runtime as openclaw_registry_runtime
from armorer.apps.openclaw import runtime_cli as openclaw_runtime_cli
from armorer.apps.openclaw import setup_cli as openclaw_setup_cli
from armorer.tools.app_setup import apply_openclaw_config
from armorer.cli import common as cli_common

console = cli_common.console
_HAS_INQUIRERPY = cli_common._HAS_INQUIRERPY
Separator = cli_common.Separator
_docker_daemon_unavailable = cli_common.docker_daemon_unavailable
_docker_cli_missing = cli_common.docker_cli_missing
_probe_docker_daemon = cli_common.probe_docker_daemon
_armorer_config_dir = cli_common.armorer_config_dir
_ui_select = cli_common.ui_select
_ui_confirm = cli_common.ui_confirm
_ui_text = cli_common.ui_text
_ui_checkbox = cli_common.ui_checkbox
_ui_choice = cli_common.ui_choice
_ui_choice_value = cli_common.ui_choice_value
_subprocess_stdio_kwargs = cli_common.subprocess_stdio_kwargs
from armorer.cli import registry_helpers as cli_registry

_resolve_install_path = cli_registry.resolve_install_path
_try_persist_registry_app_metadata = cli_registry.try_persist_registry_app_metadata
_infer_agent_id = cli_registry.infer_agent_id
_instances_for_agent = cli_registry.instances_for_agent
_get_app_with_openclaw_fallback = cli_registry.get_app_with_openclaw_fallback
_resolve_registered_app = lambda registry, agent: _get_app_with_openclaw_fallback(registry, agent)
_list_apps_with_openclaw_fallback = cli_registry.list_apps_with_openclaw_fallback
_print_agent_not_found = cli_registry.print_agent_not_found
_can_reinstall_stale_default_openclaw_alias = cli_registry.can_reinstall_stale_default_openclaw_alias
_next_instance_alias = cli_registry.next_instance_alias
from armorer.cli import core_lifecycle as cli_lifecycle

_inspect_named_container = cli_lifecycle.inspect_named_container
_is_pid_alive = cli_lifecycle.is_pid_alive
_list_running_telegram_gateway_pids = cli_lifecycle.list_running_telegram_gateway_pids
_list_running_host_core_pids = cli_lifecycle.list_running_host_core_pids
_list_running_host_ui_api_pids = cli_lifecycle.list_running_host_ui_api_pids
_advisory_lock = cli_lifecycle.advisory_lock
_release_owned_pid_file = cli_lifecycle.release_owned_pid_file
_claim_telegram_gateway_singleton = cli_lifecycle.claim_telegram_gateway_singleton
_core_provider_env = cli_lifecycle.core_provider_env
_ensure_host_core_process = cli_lifecycle.ensure_host_core_process
_ensure_telegram_gateway_autostart = cli_lifecycle.ensure_telegram_gateway_autostart
_write_otel_collector_config = cli_lifecycle.write_otel_collector_config
_ensure_otel_bridge_autostart = cli_lifecycle.ensure_otel_bridge_autostart
_ensure_self_docker_image = cli_lifecycle.ensure_self_docker_image
_best_effort_core_autostarts = cli_lifecycle.best_effort_core_autostarts
_ensure_armorer_core = cli_lifecycle.ensure_armorer_core
_ensure_armorer_ui_container = cli_lifecycle.ensure_armorer_ui_container
_ensure_host_ui_api_process = cli_lifecycle._ensure_host_ui_api_process
_resolve_managed_host_ui_api_base = cli_lifecycle.resolve_managed_host_ui_api_base
_stop_host_ui_api_process = cli_lifecycle.stop_host_ui_api_process
_resolve_ui_api_token = cli_lifecycle._resolve_ui_api_token
from armorer.cli import run as cli_run
from armorer.cli import install as cli_install
from armorer.cli import config_cmd as cli_config_cmd
from armorer.cli import chat as cli_chat
from armorer.cli import gateway as cli_gateway
from armorer.cli import core_commands as cli_core_commands
from armorer.cli import alerts as cli_alerts
from armorer.cli import monitor as cli_monitor
from armorer.cli import ebpf as cli_ebpf

_extract_run_cli_overrides = cli_run.extract_run_cli_overrides
_repair_memory_path = cli_run.repair_memory_path
_load_repair_memory = cli_run.load_repair_memory
_save_repair_memory = cli_run.save_repair_memory
_extract_error_signature = cli_run.extract_error_signature
_signature_default_action = cli_run.signature_default_action
_extract_port_conflict = cli_run.extract_port_conflict
_containers_using_port = cli_run.containers_using_port
_docker_published_ports = cli_run.docker_published_ports
_next_free_port = cli_run.next_free_port
_upsert_env_values = cli_run.upsert_env_values
_resolve_host_port_token = cli_run.resolve_host_port_token
_parse_host_port_from_string_port = cli_run.parse_host_port_from_string_port
_normalize_openclaw_run_commands = cli_run.normalize_openclaw_run_commands
_is_sensitive_text_ui_action = cli_run.is_sensitive_text_ui_action
_redact_ui_followup_for_display = cli_run.redact_ui_followup_for_display
_ui_action_prompt_message = cli_run.ui_action_prompt_message
_openclaw_is_read_only_check = cli_run.openclaw_is_read_only_check
_resolve_setup_cmd = cli_run.resolve_setup_cmd
_auto_remap_conflicting_ports = cli_run.auto_remap_conflicting_ports

app = typer.Typer(help="🛡️ Armorer: Secure AI Agent Orchestrator", no_args_is_help=False)
ui_token_app = typer.Typer(help="Show or manage the Armorer UI token.", no_args_is_help=True)
app.add_typer(ui_token_app, name="ui-token")

_CORE_PROVIDER_ENV_KEYS = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "openrouter": "OPENROUTER_API_KEY",
}


def _print_ui_token_access_hint(*, reveal: bool = False) -> None:
    token_path = _armorer_config_dir() / "ui-api-token"
    token = _resolve_ui_api_token()
    if reveal:
        console.print(
            Panel.fit(
                f"[bold]Armorer UI token[/bold]\n\n{token}\n\n"
                f"[dim]Saved at {token_path}[/dim]",
                title="UI Access",
                border_style="green",
            )
        )
        return

    console.print(f"[dim]Armorer UI token is stored at {token_path}[/dim]")
    console.print("[dim]Run `armorer ui-token show` to print it for headless access.[/dim]")


def _user_guard() -> Any:
    return telegram_admin_cli.user_guard(_armorer_config_dir())


def _proactive_settings() -> Dict[str, Any]:
    return telegram_admin_cli.proactive_settings(_armorer_config_dir())


def _load_recent_telegram_targets_cache(min_updated_at: Optional[float] = None) -> List[Dict[str, str]]:
    return telegram_admin_cli.load_recent_telegram_targets_cache(
        home_dir=Path.home(),
        min_updated_at=min_updated_at,
    )


def _wait_for_telegram_targets_from_live_bridge(
    started_at: float,
    timeout_seconds: float = 20.0,
) -> tuple[List[Dict[str, str]], bool]:
    return telegram_admin_cli.wait_for_telegram_targets_from_live_bridge(
        started_at,
        timeout_seconds=timeout_seconds,
        load_recent_targets_cache=_load_recent_telegram_targets_cache,
        time_now=time.time,
        sleep_fn=time.sleep,
    )


def _recommended_admin_chat_ids(
    *,
    alert_chat: str,
    current_admin_chat_ids: List[str],
    discovered_targets: List[Dict[str, str]],
) -> List[str]:
    return telegram_admin_cli.recommended_admin_chat_ids(
        alert_chat=alert_chat,
        current_admin_chat_ids=current_admin_chat_ids,
        discovered_targets=discovered_targets,
    )


def _recommended_admin_user_ids(
    *,
    current_admin_user_ids: List[str],
    discovered_targets: List[Dict[str, str]],
    selected_admin_chat_ids: List[str],
) -> List[str]:
    return telegram_admin_cli.recommended_admin_user_ids(
        current_admin_user_ids=current_admin_user_ids,
        discovered_targets=discovered_targets,
        selected_admin_chat_ids=selected_admin_chat_ids,
    )


def _has_advanced_telegram_admin_settings(
    *,
    current_admin_chat_ids: List[str],
    current_admin_user_ids: List[str],
    recommended_admin_chat_ids: List[str],
) -> bool:
    return telegram_admin_cli.has_advanced_telegram_admin_settings(
        current_admin_chat_ids=current_admin_chat_ids,
        current_admin_user_ids=current_admin_user_ids,
        recommended_admin_chat_ids=recommended_admin_chat_ids,
    )


def _proactive_security_subject(trigger_text: str, target_agent: Optional[str] = None) -> str:
    return telegram_admin_cli.proactive_security_subject(trigger_text, target_agent=target_agent)


def _print_telegram_bot_setup_steps(bot_username: str = "") -> None:
    telegram_admin_cli.print_telegram_bot_setup_steps(console, bot_username=bot_username)


def _configure_armorer_telegram(cfg: ConfigManager) -> Dict[str, Any]:
    return telegram_admin_cli.configure_armorer_telegram(
        cfg,
        console=console,
        armorer_config_dir=_armorer_config_dir,
        get_user_guard=_user_guard,
        ui_confirm=_ui_confirm,
        ui_select=_ui_select,
        ui_text=_ui_text,
        ui_choice=_ui_choice,
        ui_choice_value=_ui_choice_value,
        inspect_named_container_fn=_inspect_named_container,
        load_recent_targets_cache=_load_recent_telegram_targets_cache,
        wait_for_live_targets=_wait_for_telegram_targets_from_live_bridge,
        print_setup_steps=_print_telegram_bot_setup_steps,
        gateway_cls=TelegramGateway,
        has_advanced_settings_fn=_has_advanced_telegram_admin_settings,
        recommended_admin_chat_ids_fn=_recommended_admin_chat_ids,
        recommended_admin_user_ids_fn=_recommended_admin_user_ids,
    )


def _maybe_onboard_proactive_security(
    cfg: ConfigManager,
    trigger_text: str,
    target_agent: Optional[str] = None,
) -> Dict[str, Any]:
    return telegram_admin_cli.maybe_onboard_proactive_security(
        cfg,
        trigger_text,
        target_agent=target_agent,
        armorer_config_dir=_armorer_config_dir,
        get_user_guard=_user_guard,
        ui_confirm=_ui_confirm,
        configure_armorer_telegram=_configure_armorer_telegram,
    )


def _default_openclaw_model(provider: str) -> str:
    return openclaw_setup_cli.default_openclaw_model(provider)


def _parse_key_value_options(values: Optional[List[str]], option_name: str) -> Dict[str, str]:
    return app_configure_cli.parse_key_value_options(
        values,
        option_name,
        bad_parameter=typer.BadParameter,
    )


def _prompt_secret_with_help(label: str, key_name: str, already_present: bool) -> Optional[str]:
    return openclaw_setup_cli.prompt_secret_with_help(
        label,
        key_name,
        already_present,
        ui_confirm=_ui_confirm,
        password_prompt=lambda prompt: questionary.password(prompt).ask(),
    )


def _prepare_openclaw_skills(
    install_dir: Path,
    cfg: ConfigManager,
    selected_skills: List[str],
    staged_secrets: Dict[str, str],
) -> tuple[List[str], Dict[str, Dict[str, Any]], Dict[str, str]]:
    return openclaw_setup_cli.prepare_openclaw_skills(
        install_dir,
        cfg,
        selected_skills,
        staged_secrets,
        discover_skills=discover_openclaw_skills,
        check_skill=check_openclaw_skill,
    )


def _configure_openclaw_instance(app_meta: AppMetadata, install_dir: Path) -> tuple[OpenClawInstanceConfig, Dict[str, str]]:
    try:
        return openclaw_setup_cli.configure_openclaw_instance(
            app_meta,
            install_dir,
            config_cls=ConfigManager,
            load_config=load_openclaw_instance_config,
            discover_skills=discover_openclaw_skills,
            check_skill=check_openclaw_skill,
            prompt_secret=_prompt_secret_with_help,
            prepare_skills=_prepare_openclaw_skills,
            ui_select=_ui_select,
            ui_text=_ui_text,
            ui_checkbox=_ui_checkbox,
            ui_confirm=_ui_confirm,
            ui_choice=_ui_choice,
        )
    except SystemExit as exc:
        raise typer.Exit(exc.code)


def _prompt_openclaw_pairing_followup(install_dir: Path, enabled_channels: List[str]) -> Dict[str, Any]:
    return openclaw_setup_cli.prompt_openclaw_pairing_followup(
        install_dir,
        enabled_channels,
        discover_pairing_requests=discover_openclaw_pairing_requests,
        approve_pairings=approve_openclaw_pairings,
        ui_text=_ui_text,
        ui_confirm=_ui_confirm,
    )


def _release_tag_sort_key(tag: str) -> Optional[Tuple[int, int, int]]:
    match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", str(tag or "").strip())
    if not match:
        return None
    return tuple(int(part) for part in match.groups())


def _latest_local_release_tag(install_dir: Path) -> Optional[str]:
    result = subprocess.run(
        ["git", "tag", "--list"],
        cwd=install_dir,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        return None
    latest: Optional[str] = None
    latest_key: Optional[Tuple[int, int, int]] = None
    for raw_line in (result.stdout or "").splitlines():
        tag = str(raw_line or "").strip()
        key = _release_tag_sort_key(tag)
        if key is None:
            continue
        if latest_key is None or key > latest_key:
            latest = tag
            latest_key = key
    return latest


def _latest_remote_release_tag(install_dir: Path) -> Optional[str]:
    result = subprocess.run(
        ["git", "ls-remote", "--tags", "origin"],
        cwd=install_dir,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        return None
    latest: Optional[str] = None
    latest_key: Optional[Tuple[int, int, int]] = None
    for raw_line in (result.stdout or "").splitlines():
        _, _, ref = str(raw_line or "").partition("refs/tags/")
        tag = ref.strip()
        if not tag or "^{}" in tag or "-" in tag:
            continue
        key = _release_tag_sort_key(tag)
        if key is None:
            continue
        if latest_key is None or key > latest_key:
            latest = tag
            latest_key = key
    return latest


def _has_newer_openclaw_release(install_dir: Path) -> bool:
    local_tag = _latest_local_release_tag(install_dir)
    remote_tag = _latest_remote_release_tag(install_dir)
    local_key = _release_tag_sort_key(local_tag or "")
    remote_key = _release_tag_sort_key(remote_tag or "")
    if local_key is None or remote_key is None:
        return False
    return remote_key > local_key


def _provider_model_suggestions(provider: str) -> List[str]:
    curated = {
        "openai": ["gpt-5.4", "gpt-5-mini", "gpt-4.1"],
        "gemini": [
            "gemini-3.1-pro-preview",
            "gemini-3.1-flash-lite-preview",
            "gemini-3-flash-preview",
        ],
        "anthropic": ["claude-sonnet-4.5", "claude-opus-4.1"],
    }
    return list(curated.get(str(provider or "").strip().lower(), []))


def _select_model_id_for_provider(
    provider: str,
    current_model: str,
    *,
    select_fn=_ui_select,
    text_fn=_ui_text,
) -> str:
    normalized_provider = str(provider or "").strip().lower()
    if normalized_provider == "ollama":
        return str(text_fn("Model id", default=current_model or "") or "").strip()
    suggestions = _provider_model_suggestions(normalized_provider)
    if suggestions:
        choices = list(suggestions) + ["Enter custom model ID"]
        default_choice = current_model if current_model in suggestions else suggestions[0]
        selected = select_fn("Select model id", choices, default=default_choice)
        if selected != "Enter custom model ID":
            return str(selected or "").strip()
    return str(text_fn("Model id", default=current_model or "") or "").strip()


def _is_repo_behind_upstream(install_dir: Path) -> bool:
    return False


def _app_configure_impl(agent: str) -> None:
    # "Armorer Config" shortcut entrypoint used by `armorer run` action menu.
    # Use the full managed setup (service-backed discovery, validation, tracking)
    # instead of the simple local-only configure flow.
    load_dotenv(override=True)
    _check_notifications()
    if _infer_agent_id(agent) == "openclaw":
        rc = _run_armorer_managed_openclaw_setup(agent)
        if rc != 0:
            raise typer.Exit(rc)
        return
    app_configure_cli.run_app_configure(
        agent=agent,
        provider=None,
        model=None,
        channel=None,
        skill=None,
        web_search_provider=None,
        secret=None,
        pairing_code=None,
        skip_bind=False,
        load_dotenv_fn=load_dotenv,
        check_notifications=_check_notifications,
        resolve_target=_resolve_configure_target,
        infer_agent_id=_infer_agent_id,
        resolve_install_path=_resolve_install_path,
        parse_key_value=_parse_key_value_options,
        interactive_configure=_configure_openclaw_instance,
        prompt_pairing_followup=_prompt_openclaw_pairing_followup,
        apply_config=apply_openclaw_config,
        console=console,
        typer_exit=typer.Exit,
    )


if not cli_lifecycle.should_skip_self_dockerize():
    cli_lifecycle.check_self_dockerize()


def _print_logo():
    """Print a centered ASCII logo."""
    logo_text = """
 █▀▀█ █▀▀█ █▀▄▀█ █▀▀█ █▀▀█ █▀▀ █▀▀█
 █▄▄█ █▄▄▀ █ ▀ █ █  █ █▄▄▀ █▀▀ █▄▄▀
 ▀  ▀ ▀ ▀▀ ▀   ▀ ▀▀▀▀ ▀ ▀▀ ▀▀▀ ▀ ▀▀
    """
    # Constrain width for better look on wide terminals
    width = min(console.width, 100)
    console.print(Align.center(Text(logo_text, style="bold cyan"), width=width))
    console.print(Align.center(Text("S E C U R E   A G E N T   O R C H E S T R A T O R", style="dim cyan"), width=width))
    console.print()


def _check_notifications(max_display: int = 8):
    """Check for and display unread security notifications."""
    events_file = _armorer_config_dir() / "events.json"
    if not events_file.exists():
        return
    try:
        cache_key = hashlib.sha256(str(events_file).encode("utf-8")).hexdigest()[:16]
        cache_path = Path(tempfile.gettempdir()) / f"armorer-notify-state-{cache_key}.json"
        last_shown_ts = 0.0
        shown_event_hashes: set[str] = set()
        if cache_path.exists():
            try:
                cache_row = json.loads(cache_path.read_text())
                if isinstance(cache_row, dict):
                    last_shown_ts = float(cache_row.get("last_shown_ts") or 0.0)
                    hashes = cache_row.get("shown_event_hashes")
                    if isinstance(hashes, list):
                        shown_event_hashes = {str(x).strip() for x in hashes if str(x).strip()}
                else:
                    last_shown_ts = 0.0
            except Exception:
                last_shown_ts = 0.0
                shown_event_hashes = set()

        def _event_cache_fingerprint(event: dict, row_index: int) -> str:
            if not isinstance(event, dict):
                return ""
            try:
                ts_raw = event.get("timestamp")
                ts_value = float(ts_raw or 0.0)
                has_numeric_ts = bool(ts_raw)
            except Exception:
                ts_value = 0.0
                has_numeric_ts = False
            ts_token = f"{ts_value:.6f}" if (has_numeric_ts and ts_value) else f"idx:{max(0, int(row_index))}"
            payload = "|".join(
                [
                    str(event.get("type") or "").strip().upper(),
                    str(event.get("agent") or "").strip(),
                    str(event.get("description") or "").strip(),
                    ts_token,
                ]
            )
            if not payload.strip("|"):
                return ""
            return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]

        with open(events_file, "r") as f:
            events = json.load(f)
        if not isinstance(events, list):
            return

        all_unread_indexes = [
            idx
            for idx, event in enumerate(events)
            if isinstance(event, dict) and bool(event.get("unread"))
        ]
        if not all_unread_indexes:
            return

        unread_indexes: list[int] = []
        unread_hashes_this_run: set[str] = set()
        for idx in all_unread_indexes:
            event = events[idx] if isinstance(events[idx], dict) else {}
            event_hash = _event_cache_fingerprint(event, idx)
            if event_hash and event_hash in shown_event_hashes:
                continue
            try:
                event_ts = float(event.get("timestamp") or 0.0)
            except Exception:
                event_ts = 0.0
            if last_shown_ts and event_ts and event_ts <= last_shown_ts:
                continue
            unread_indexes.append(idx)
            if event_hash:
                unread_hashes_this_run.add(event_hash)

        limit = max(1, int(max_display or 0))
        display_indexes: list[int] = []
        seen: set[tuple[str, str, str]] = set()
        for idx in reversed(unread_indexes):
            event = events[idx] if isinstance(events[idx], dict) else {}
            key = (
                str(event.get("type") or "").strip().upper(),
                str(event.get("agent") or "").strip(),
                str(event.get("description") or "").strip(),
            )
            if key in seen:
                continue
            seen.add(key)
            display_indexes.append(idx)
            if len(display_indexes) >= limit:
                break
        display_indexes.reverse()
        suppressed = len(all_unread_indexes) - len(display_indexes)

        if display_indexes:
            console.print()
            if suppressed > 0:
                console.print(
                    f"[yellow]{suppressed} older or duplicate unread security events suppressed; "
                    f"showing latest {len(display_indexes)}.[/yellow]"
                )

            for idx in display_indexes:
                event = events[idx] if isinstance(events[idx], dict) else {}
                event_type = str(event.get("type") or "ALERT").upper()
                color = "red" if event_type == "ALERT" else "yellow"
                agent = str(event.get("agent") or "unknown")
                description = str(event.get("description") or "No description provided.")
                try:
                    dt = datetime.datetime.fromtimestamp(float(event.get("timestamp"))).strftime("%H:%M:%S")
                except Exception:
                    dt = "unknown"
                console.print(
                    Panel(
                        f"[bold {color}]{event_type}[/bold {color}] - {agent}\n{description}",
                        title=f"Security Event @ {dt}",
                        border_style=color,
                        expand=False,
                    )
                )

        latest_displayed_ts = 0.0
        for idx in display_indexes:
            event = events[idx] if isinstance(events[idx], dict) else {}
            try:
                event_ts = float(event.get("timestamp") or 0.0)
            except Exception:
                event_ts = 0.0
            if event_ts > latest_displayed_ts:
                latest_displayed_ts = event_ts

        for idx in all_unread_indexes:
            row = events[idx]
            if isinstance(row, dict):
                row["unread"] = False

        persisted = False
        try:
            with open(events_file, "w") as f:
                json.dump(events, f, indent=4)
            persisted = True
        except Exception:
            persisted = False

        if latest_displayed_ts > 0 or unread_hashes_this_run:
            merged_hashes = shown_event_hashes | unread_hashes_this_run
            ordered_hashes = sorted(merged_hashes)
            if len(ordered_hashes) > 256:
                ordered_hashes = ordered_hashes[-256:]
            try:
                cache_path.write_text(
                    json.dumps(
                        {
                            "last_shown_ts": latest_displayed_ts,
                            "shown_event_hashes": ordered_hashes,
                            "updated_at": time.time(),
                            "persisted": persisted,
                        },
                        sort_keys=True,
                    )
                )
            except Exception:
                pass
        if display_indexes:
            console.print()
    except Exception:
        pass

def _record_repair_memory(
    agent: str,
    signature: str,
    diagnosis: str,
    action: Optional[Dict[str, Any]] = None,
) -> None:
    memory = _load_repair_memory()
    entries = memory.setdefault("entries", {})
    key = f"{agent}::{signature}"
    entry = entries.get(key, {})
    entry["agent"] = agent
    entry["signature"] = signature
    entry["hits"] = int(entry.get("hits", 0)) + 1
    entry["last_seen"] = int(time.time())
    entry["last_diagnosis"] = (diagnosis or "")[:2000]
    if action:
        entry["action"] = action
        entry["auto_apply"] = True
    entries[key] = entry
    _save_repair_memory(memory)


def _apply_memory_action(install_dir: Path, action: Dict[str, Any]) -> bool:
    if not action or not isinstance(action, dict):
        return False
    kind = action.get("kind")
    if kind == "compose_down":
        from armorer import docker_runner

        args = action.get("args") or []
        # Only support simple flag args here (e.g. ["-v"])
        volumes = "-v" in args or "--volumes" in args
        res = docker_runner.compose_down(cwd=install_dir, volumes=volumes)
        return res.returncode == 0
    return False


def _apply_learned_repairs_before_run(agent: str, install_dir: Path) -> None:
    memory = _load_repair_memory()
    entries = list(memory.get("entries", {}).values())
    matches = [e for e in entries if e.get("agent") == agent and e.get("auto_apply") and e.get("action")]
    # Apply only fixes that have appeared more than once (frequent pattern)
    matches = [e for e in matches if int(e.get("hits", 0)) >= 2]
    matches.sort(key=lambda x: int(x.get("hits", 0)), reverse=True)
    for entry in matches[:2]:
        action = entry.get("action", {})
        if _apply_memory_action(install_dir, action):
            label = action.get("label") or action.get("kind", "repair")
            console.print(f"[dim]Applied learned pre-run repair: {label}[/dim]")


def _parse_compose_ps_entries(install_dir: Path) -> tuple[List[Dict[str, Any]], str]:
    try:
        from armorer import docker_runner

        res = docker_runner.compose_ps(
            cwd=install_dir,
            args=["-a", "--format", "json"],
            timeout=10,
        )
        if res.returncode != 0:
            detail = ((res.stderr or "") + "\n" + (res.stdout or "")).strip()
            return [], detail
        if not res.stdout.strip():
            return [], ""
        raw = res.stdout.strip()
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, list):
                return [p for p in parsed if isinstance(p, dict)], ""
            if isinstance(parsed, dict):
                return [parsed], ""
        except json.JSONDecodeError:
            rows = []
            for line in raw.splitlines():
                try:
                    item = json.loads(line)
                    if isinstance(item, dict):
                        rows.append(item)
                except json.JSONDecodeError:
                    continue
            return rows, ""
    except Exception as exc:
        return [], str(exc)
    return [], ""


def _is_expected_zero_exit(service_name: str, exit_code: int) -> bool:
    name = (service_name or "").lower()
    if "cli" in name:
        return True
    if exit_code != 0:
        return False
    expected = ("migrate", "init", "setup", "seed", "job", "cli", "deps", "bootstrap", "prepare")
    return any(tok in name for tok in expected)


def _detect_stack_instability(install_dir: Path) -> Optional[str]:
    rows, _ps_error = _parse_compose_ps_entries(install_dir)
    if not rows:
        return "No compose services detected after startup."
    bad: List[str] = []
    for row in rows:
        svc = row.get("Service", row.get("Name", "unknown"))
        state = str(row.get("State", "")).lower()
        status = str(row.get("Status", ""))
        health = str(row.get("Health", "")).lower()
        exit_code = int(row.get("ExitCode", 0) or 0)
        if "restarting" in state:
            bad.append(f"{svc}: restarting ({status})")
            continue
        if "unhealthy" in health:
            bad.append(f"{svc}: unhealthy ({status})")
            continue
        if "exited" in state and not _is_expected_zero_exit(str(svc), exit_code):
            bad.append(f"{svc}: exited({exit_code}) ({status})")
    if bad:
        return "\n".join(bad[:8])
    return None


def _defined_compose_services(install_dir: Path) -> List[str]:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return []
    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
        services = data.get("services", {})
        if not isinstance(services, dict):
            return []
        return [str(name) for name in services.keys()]
    except Exception:
        return []


def _recent_compose_diagnostics(install_dir: Path, services: Optional[List[str]] = None) -> str:
    details: List[str] = []
    try:
        from armorer import docker_runner

        ps_result = docker_runner.compose_ps(
            cwd=install_dir,
            args=["--all"],
            timeout=15,
        )
        ps_text = ((ps_result.stdout or "") + (ps_result.stderr or "")).strip()
        if ps_text:
            details.append("compose ps:\n" + ps_text)
    except Exception:
        pass

    try:
        from armorer import docker_runner

        log_result = docker_runner.compose_logs(
            cwd=install_dir,
            tail=60,
            services=services,
            timeout=20,
        )
        log_text = ((log_result.stdout or "") + (log_result.stderr or "")).strip()
        if log_text:
            details.append("recent logs:\n" + log_text)
    except Exception:
        pass

    return "\n\n".join(details).strip()


def _compose_service_state(install_dir: Path, service_name: str) -> tuple[str, bool]:
    try:
        from armorer import docker_runner

        res = docker_runner.compose_ps(
            cwd=install_dir,
            args=["--format", "json"],
            timeout=8,
        )
        if res.returncode == 0 and (res.stdout or "").strip():
            entries: List[Dict[str, Any]] = []
            raw = (res.stdout or "").strip()
            try:
                parsed = json.loads(raw)
                if isinstance(parsed, list):
                    entries = [item for item in parsed if isinstance(item, dict)]
                elif isinstance(parsed, dict):
                    entries = [parsed]
            except json.JSONDecodeError:
                for line in raw.splitlines():
                    try:
                        item = json.loads(line)
                        if isinstance(item, dict):
                            entries.append(item)
                    except json.JSONDecodeError:
                        continue
            for item in entries:
                if str(item.get("Service") or item.get("service") or "").strip() != service_name:
                    continue
                state = str(item.get("State") or item.get("state") or item.get("Status") or item.get("status") or "").strip()
                low = state.lower()
                return state or "unknown", low in {"running", "healthy"} or "running" in low or "healthy" in low
    except Exception:
        pass
    return "stopped", False


def _compose_service_is_running(install_dir: Path, service_name: str) -> bool:
    return _compose_service_state(install_dir, service_name)[1]


def _ensure_startup_services_ready(
    install_dir: Path,
    startup_services: List[str],
    timeout_seconds: int = 90,
    runtime_env: Optional[Dict[str, str]] = None,
) -> bool:
    services = [s for s in (startup_services or []) if s]
    if not services:
        return True
    startup_timeout = max(10, int(timeout_seconds))
    try:
        from armorer import docker_runner

        up = docker_runner.compose_up(
            cwd=install_dir,
            services=services,
            env=runtime_env,
            timeout=startup_timeout,
        )
    except Exception as exc:
        detail = str(exc).strip()
        console.print(f"[yellow]Failed to start prerequisite services:[/yellow] {detail[:600]}")
        if _docker_cli_missing(detail):
            console.print(
                "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
            )
        elif _docker_daemon_unavailable(detail):
            console.print(
                "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
            )
        return False

    if up.returncode == 124 and "timeout" in (up.stderr or "").lower():
        timeout_value = startup_timeout
        console.print(
            f"[yellow]Failed to start prerequisite services:[/yellow] timed out after {timeout_value}s"
        )
        return False
    if up.returncode != 0:
        detail = ((up.stderr or "") + "\n" + (up.stdout or "")).strip()
        console.print(f"[yellow]Failed to start prerequisite services:[/yellow] {detail[:600]}")
        if _docker_cli_missing(detail):
            console.print(
                "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
            )
        elif _docker_daemon_unavailable(detail):
            console.print(
                "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
            )
        return False

    # Keep gateway probe wait bounded for status/doctor flows so CLI runs
    # remain responsive even when startup profile requests a larger timeout.
    wait_budget = max(10, min(int(timeout_seconds), 75))
    deadline = time.time() + wait_budget
    while time.time() < deadline:
        rows, ps_error = _parse_compose_ps_entries(install_dir)
        if not rows:
            if ps_error:
                if _docker_cli_missing(ps_error):
                    console.print(f"[yellow]Failed to inspect prerequisite services:[/yellow] {ps_error[:600]}")
                    console.print(
                        "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                    )
                    return False
                if _docker_daemon_unavailable(ps_error):
                    console.print(f"[yellow]Failed to inspect prerequisite services:[/yellow] {ps_error[:600]}")
                    console.print(
                        "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                    )
                    return False
            time.sleep(1.5)
            continue
        state_by_service: Dict[str, Dict[str, Any]] = {}
        for row in rows:
            name = str(row.get("Service", row.get("Name", "")) or "").strip()
            if name:
                state_by_service[name] = row
        all_ready = True
        for svc in services:
            row = state_by_service.get(svc)
            if not row:
                all_ready = False
                break
            state = str(row.get("State", "")).lower()
            health = str(row.get("Health", "")).lower()
            status_text = str(row.get("Status", "")).lower()
            if "restarting" in state or "exited" in state:
                all_ready = False
                break
            # If a healthcheck exists, wait for healthy rather than starting.
            if health and health != "healthy":
                all_ready = False
                break
            # Some compose versions only expose health state in Status text.
            if "health:" in status_text and "healthy" not in status_text:
                all_ready = False
                break
            if "running" not in state and "up" not in str(row.get("Status", "")).lower():
                all_ready = False
                break
        if all_ready:
            return True
        time.sleep(1.5)

    details = _recent_compose_diagnostics(install_dir, services)
    console.print("[yellow]Startup services did not become ready in time.[/yellow]")
    if details:
        console.print(f"[dim]{details}[/dim]")
    return False


def _should_ensure_prereq_services(
    prereq_services: List[str],
    service_name: Optional[str],
) -> bool:
    services = [s for s in (prereq_services or []) if s]
    if not services:
        return False
    selected = str(service_name or "").strip()
    # If the selected shortcut already targets a prerequisite daemon service,
    # avoid pre-starting it separately to prevent duplicate-instance collisions.
    if selected and selected in services:
        return False
    return True


def _openclaw_gateway_exec_override(
    agent: str,
    service_name: Optional[str],
    run_args: List[str],
) -> Optional[List[str]]:
    return openclaw_runtime_cli.openclaw_gateway_exec_override(
        agent,
        service_name,
        run_args,
        infer_agent_id=_infer_agent_id,
        is_read_only_check=_openclaw_is_read_only_check,
    )


def _wait_openclaw_gateway_reachable(
    compose_dir: Path,
    run_env: Dict[str, str],
    timeout_seconds: int = 60,
) -> bool:
    result = openclaw_runtime_cli.wait_openclaw_gateway_reachable(
        compose_dir,
        run_env,
        console=console,
        docker_cli_missing=_docker_cli_missing,
        docker_daemon_unavailable=_docker_daemon_unavailable,
        timeout_seconds=timeout_seconds,
    )
    setattr(
        _wait_openclaw_gateway_reachable,
        "last_failure_is_fatal",
        bool(getattr(openclaw_runtime_cli.wait_openclaw_gateway_reachable, "last_failure_is_fatal", False)),
    )
    return result


def _collect_compose_profiles(install_dir: Path) -> List[str]:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return []
    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
        services = data.get("services", {})
        profiles: List[str] = []
        for svc in services.values():
            if not isinstance(svc, dict):
                continue
            prof = svc.get("profiles")
            if isinstance(prof, str):
                profiles.append(prof)
            elif isinstance(prof, list):
                profiles.extend([p for p in prof if isinstance(p, str)])
        out: List[str] = []
        seen = set()
        for p in profiles:
            if p not in seen:
                seen.add(p)
                out.append(p)
        return out
    except Exception:
        return []


def _is_profile_only_compose(install_dir: Path) -> bool:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return False
    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
        services = data.get("services", {})
        if not isinstance(services, dict) or not services:
            return False
        for svc in services.values():
            if not isinstance(svc, dict):
                return False
            if not svc.get("profiles"):
                return False
        return True
    except Exception:
        return False


def _looks_like_openclaw_runtime_dir(path: Path) -> bool:
    return openclaw_registry_runtime.looks_like_openclaw_runtime_dir(path)


def _resolve_openclaw_runtime_dir(install_dir: Path, compose_subdir: str = ".") -> Optional[Path]:
    return openclaw_registry_runtime.resolve_openclaw_runtime_dir(install_dir, compose_subdir)


def _fallback_openclaw_default_app(registry: AppRegistry, agent: str) -> Optional[AppMetadata]:
    return openclaw_registry_runtime.fallback_openclaw_default_app(
        registry,
        agent,
        infer_agent_id=_infer_agent_id,
        persist_registry_app_metadata=_try_persist_registry_app_metadata,
    )


def _ensure_runtime_path_env_defaults(install_dir: Path) -> None:
    needed = _runtime_path_env_defaults(install_dir)
    if not needed:
        return
    _persist_runtime_path_env_defaults(install_dir, needed)


def _runtime_path_env_defaults(install_dir: Path) -> Dict[str, str]:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return {}
    try:
        compose_text = compose_path.read_text()
    except Exception:
        return {}
    agent_name = install_dir.name
    hints = load_agent_hints(agent_name)
    defaults = hints.get("env_defaults", {}) if isinstance(hints, dict) else {}
    if not isinstance(defaults, dict):
        defaults = {}
    return {k: v for k, v in defaults.items() if f"${{{k}" in compose_text}


def _persist_runtime_path_env_defaults(install_dir: Path, needed: Dict[str, str]) -> None:
    agent_name = install_dir.name
    hints = load_agent_hints(agent_name)
    env_path = install_dir / ".env"
    if not env_path.exists():
        env_path.write_text("")
    try:
        lines = env_path.read_text().splitlines()
    except Exception:
        lines = []
    existing = {}
    for ln in lines:
        if not ln or ln.strip().startswith("#") or "=" not in ln:
            continue
        k, v = ln.split("=", 1)
        existing[k.strip()] = v.strip()

    added = []
    for key, val in needed.items():
        if existing.get(key):
            continue
        added.append(f"{key}={val}")
        try:
            (install_dir / val).resolve().mkdir(parents=True, exist_ok=True)
        except Exception:
            pass
    if added:
        with open(env_path, "a") as f:
            if lines and lines[-1].strip():
                f.write("\n")
            f.write("\n".join(added) + "\n")
        console.print("[dim]Applied runtime path defaults for compose volumes.[/dim]")

    create_files = hints.get("create_files", []) if isinstance(hints, dict) else []
    if isinstance(create_files, list):
        for item in create_files:
            if not isinstance(item, dict):
                continue
            rel_path = item.get("path")
            content = item.get("content")
            if not isinstance(rel_path, str) or content is None:
                continue
            rendered = rel_path
            for key, value in {**needed, **existing}.items():
                if isinstance(value, str):
                    rendered = rendered.replace(f"${{{key}}}", value)
            try:
                target = (install_dir / rendered).resolve() if rendered.startswith(".") else Path(rendered).expanduser()
                target.parent.mkdir(parents=True, exist_ok=True)
                if not target.exists():
                    if isinstance(content, (dict, list)):
                        target.write_text(json.dumps(content, indent=2) + "\n")
                    else:
                        target.write_text(str(content))
                    console.print(f"[dim]Created bootstrap file: {target.name}[/dim]")
            except Exception:
                continue


def _ensure_runtime_local_images(install_dir: Path, runtime_env: Optional[Dict[str, str]] = None) -> None:
    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return
    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
    except Exception:
        return
    hints = load_agent_hints(install_dir.name)
    hinted_local_images = set(hints.get("local_images", [])) if isinstance(hints, dict) else set()
    services = data.get("services", {})
    if not isinstance(services, dict):
        return
    env_values: Dict[str, str] = {}
    try:
        from dotenv import dotenv_values
        env_path = install_dir / ".env"
        if env_path.exists():
            env_values = {
                str(k): str(v)
                for k, v in dotenv_values(env_path).items()
                if k and v is not None
            }
    except Exception:
        env_values = {}
    if isinstance(runtime_env, dict):
        env_values.update(
            {
                str(k): str(v)
                for k, v in runtime_env.items()
                if k and v is not None
            }
        )

    for svc_name, cfg in services.items():
        if not isinstance(cfg, dict):
            continue
        image = cfg.get("image")
        if not isinstance(image, str):
            continue
        image = image.strip()
        if "${" in image:
            # Resolve common compose image refs like:
            #   ${OPENCLAW_IMAGE:-openclaw:local}
            #   ${SOME_IMAGE}
            full_ref = re.match(r"^\$\{([A-Z_][A-Z0-9_]*)(?::-(.+))?\}$", image)
            if full_ref:
                var_name = full_ref.group(1)
                resolved = (os.environ.get(var_name) or env_values.get(var_name) or "").strip()
                if not resolved:
                    console.print(
                        f"[yellow]Runtime image variable '{var_name}' is not set for service '{svc_name}'. "
                        "Skipping implicit fallback image resolution.[/yellow]"
                    )
                    continue
                image = resolved
            else:
                # Unhandled interpolation pattern; do not guess defaults.
                continue
        if not image.endswith(":local") and image not in hinted_local_images:
            continue
        from armorer import docker_runner
        chk = docker_runner.docker_image_inspect(image)
        if chk.returncode == 0:
            continue
        dockerfile = install_dir / "Dockerfile"
        if dockerfile.exists():
            console.print(f"[dim]Building missing local image: {image}[/dim]")
            docker_runner.docker_build(context=install_dir, tag=image)


def _build_runtime_env(install_dir: Path) -> Dict[str, str]:
    run_env = os.environ.copy()
    getuid = getattr(os, "getuid", None)
    getgid = getattr(os, "getgid", None)
    if callable(getuid):
        run_env.setdefault("UID", str(getuid()))
    if callable(getgid):
        run_env.setdefault("GID", str(getgid()))
    from dotenv import dotenv_values
    env_vars = dotenv_values(install_dir / ".env")
    for key in ("OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"):
        raw = str(env_vars.get(key) or "").strip()
        if not raw:
            continue
        abs_p = Path(raw).expanduser()
        if not abs_p.is_absolute():
            abs_p = (install_dir / abs_p).resolve()
        if os.path.exists("/.dockerenv"):
            abs_p = _resolve_install_path(str(abs_p), to_host=True)
        run_env[key] = str(abs_p)
    if not os.path.exists("/.dockerenv"):
        return run_env
    for key, val in env_vars.items():
        if key in {"OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"}:
            continue
        if val and (val.startswith("/") or val.startswith("./") or val.startswith("../")):
            abs_p = (install_dir / val).resolve()
            host_p = _resolve_install_path(str(abs_p), to_host=True)
            run_env[key] = str(host_p)
    # Defensive remap: if any compose path variable still points to /app inside sandbox,
    # force conversion back to host absolute path.
    for key in ("OPENCLAW_CONFIG_DIR", "OPENCLAW_WORKSPACE_DIR"):
        cur = str(run_env.get(key) or "").strip()
        if cur.startswith("/app/"):
            run_env[key] = str(_resolve_install_path(cur, to_host=True))
    return run_env


def _runtime_security_approval_path(install_dir: Path) -> Path:
    return install_dir / ".armorer" / "runtime-security-approvals.json"


def _load_runtime_security_approvals(install_dir: Path) -> Dict[str, bool]:
    approval_path = _runtime_security_approval_path(install_dir)
    if not approval_path.exists():
        return {}
    try:
        data = json.loads(approval_path.read_text())
        if isinstance(data, dict):
            return {str(k): bool(v) for k, v in data.items()}
    except Exception:
        return {}
    return {}


def _save_runtime_security_approvals(install_dir: Path, approvals: Dict[str, bool]) -> None:
    approval_path = _runtime_security_approval_path(install_dir)
    try:
        approval_path.parent.mkdir(parents=True, exist_ok=True)
        approval_path.write_text(json.dumps(approvals, indent=2) + "\n")
    except Exception:
        pass


def _detect_runtime_security_changes(compose_dir: Path) -> List[Dict[str, str]]:
    compose_path = compose_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = compose_dir / "docker-compose.yaml"
    if not compose_path.exists():
        return []
    try:
        data = yaml.safe_load(compose_path.read_text()) or {}
    except Exception:
        return []
    services = data.get("services", {})
    if not isinstance(services, dict):
        return []

    findings: Dict[str, Dict[str, str]] = {}

    def add_change(change_id: str, summary: str, reason: str) -> None:
        findings.setdefault(change_id, {"id": change_id, "summary": summary, "reason": reason})

    for svc_name, svc in services.items():
        if not isinstance(svc, dict):
            continue
        volumes = svc.get("volumes") or []
        if not isinstance(volumes, list):
            volumes = [volumes]
        for vol in volumes:
            raw = ""
            if isinstance(vol, str):
                raw = vol
            elif isinstance(vol, dict):
                raw = str(vol.get("source") or "")
            if "/var/run/docker.sock" in raw:
                add_change(
                    "docker_socket",
                    "Docker socket access",
                    f"Service '{svc_name}' mounts the host Docker socket and can control host containers.",
                )

        if bool(svc.get("privileged")):
            add_change(
                "privileged",
                "Privileged container",
                f"Service '{svc_name}' requests privileged mode and broad kernel/device access.",
            )

        if str(svc.get("network_mode") or "").strip().lower() == "host":
            add_change(
                "host_network",
                "Host network access",
                f"Service '{svc_name}' uses host networking and bypasses network isolation.",
            )

        if str(svc.get("pid") or "").strip().lower() == "host":
            add_change(
                "host_pid",
                "Host PID namespace",
                f"Service '{svc_name}' shares the host PID namespace.",
            )

        cap_add = svc.get("cap_add") or []
        if isinstance(cap_add, str):
            cap_add = [cap_add]
        if cap_add:
            caps = ", ".join(str(c) for c in cap_add)
            add_change(
                "extra_capabilities",
                "Extra Linux capabilities",
                f"Service '{svc_name}' adds Linux capabilities: {caps}.",
            )

    return list(findings.values())


def _ensure_runtime_security_approvals(
    agent: str,
    install_dir: Path,
    compose_dir: Path,
    requested: Optional[List[str]] = None,
) -> None:
    changes = _detect_runtime_security_changes(compose_dir)
    if not changes:
        return

    approvals = _load_runtime_security_approvals(install_dir)
    requested_set = {str(item).strip() for item in (requested or []) if str(item).strip()}

    missing = [change for change in changes if not approvals.get(change["id"]) and change["id"] not in requested_set]
    if not missing:
        if requested_set:
            for change in changes:
                if change["id"] in requested_set:
                    approvals[change["id"]] = True
            _save_runtime_security_approvals(install_dir, approvals)
        return

    if sys.stdin.isatty():
        console.print("[yellow]Runtime security approval required:[/yellow]")
        for change in missing:
            console.print(f"- [bold]{change['summary']}[/bold]: {change['reason']}")
        if not _ui_confirm(f"Approve these security changes for '{agent}' and continue?", default=False):
            raise typer.Exit(1)
        for change in missing:
            approvals[change["id"]] = True
        _save_runtime_security_approvals(install_dir, approvals)
        console.print("[dim]Recorded runtime security approvals for this installation.[/dim]")
        return

    required = " ".join(f"--approve-security-change {change['id']}" for change in missing)
    console.print("[red]Runtime security approval required before starting this agent.[/red]")
    for change in missing:
        console.print(f"- [bold]{change['summary']}[/bold]: {change['reason']}")
    console.print(f"[dim]Retry with: armorer run {agent} {required}[/dim]")
    raise typer.Exit(1)


def _looks_like_placeholder(value: str) -> bool:
    v = (value or "").strip().lower()
    if not v:
        return True
    return v in {"change-me", "changeme", "your-token", "your-api-key", "placeholder"} or "change-me" in v


def _parse_env_keys_from_file(path: Path) -> List[str]:
    if not path.exists():
        return []
    keys: List[str] = []
    try:
        for ln in path.read_text().splitlines():
            line = ln.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k = line.split("=", 1)[0].strip()
            if k:
                keys.append(k)
    except Exception:
        return []
    return keys


def _prefill_from_vault_checklist(agent: str, install_dir: Path, command_label: str, run_args: List[str]) -> None:
    if not sys.stdin.isatty():
        return
    normalized = " ".join([command_label] + (run_args or [])).lower()
    if not any(k in normalized for k in ("onboard", "configure", "setup")):
        return

    env_path = install_dir / ".env"
    if not env_path.exists():
        return

    try:
        from dotenv import dotenv_values
        existing = dotenv_values(env_path)
    except Exception:
        existing = {}

    key_set = set(_parse_env_keys_from_file(env_path))
    for candidate in (".env.example", ".env.sample", ".env.default"):
        key_set.update(_parse_env_keys_from_file(install_dir / candidate))

    secretish = [
        k for k in sorted(key_set)
        if any(t in k for t in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
    ]
    if not secretish:
        return

    cfg = ConfigManager()
    candidates = []
    values_to_apply: Dict[str, str] = {}
    for key in secretish:
        current = existing.get(key)
        if current and not _looks_like_placeholder(str(current)):
            continue
        vault_val = cfg.find_vault_key(key)
        if vault_val:
            candidates.append(key)
            values_to_apply[key] = vault_val
    if not candidates:
        return

    if not _ui_confirm(
        f"Found {len(candidates)} matching Vault secret(s) for onboarding. Prefill now?",
        default=True,
    ):
        return

    choices = [
        {"name": f"{k}  [Vault match]", "value": k, "enabled": True}
        for k in candidates
    ]
    selected = _ui_checkbox(
        "Select secrets to prefill into local .env:",
        choices=choices,
    )
    if not selected:
        return

    lines = env_path.read_text().splitlines()
    index_by_key: Dict[str, int] = {}
    for i, ln in enumerate(lines):
        if "=" in ln and not ln.lstrip().startswith("#"):
            k = ln.split("=", 1)[0].strip()
            if k:
                index_by_key[k] = i

    for key in selected:
        val = values_to_apply.get(key, "")
        new_line = f"{key}={val}"
        if key in index_by_key:
            lines[index_by_key[key]] = new_line
        else:
            lines.append(new_line)

    env_path.write_text("\n".join(lines).rstrip() + "\n")
    console.print(f"[green]✓ Prefilled {len(selected)} value(s) from Vault into {env_path}[/green]")


def _describe_run_command(cmd_data: Dict[str, Any]) -> str:
    """Human-friendly explanation shown in run menus."""
    explicit = str(cmd_data.get("description", "") or "").strip()
    if explicit:
        return explicit

    label = str(cmd_data.get("label", "")).lower()
    command = str(cmd_data.get("command", "")).lower()
    text = f"{label} {command}"

    if "onboard" in text:
        return "First-time onboarding: connects accounts/channels and creates initial local state."
    if "configure" in text:
        return "Configuration wizard: sets provider/model and local CLI preferences."
    if "setup" in text:
        return "Initial project setup: prepares local config and required runtime values."
    if "status" in text:
        return "Shows current gateway/service status."
    if "doctor" in text or "health" in text:
        return "Runs diagnostics and reports problems with suggested fixes."
    if "tui" in text:
        return "Opens the interactive terminal UI."
    return "Runs this agent command inside the CLI service container."


def _truncate_menu_text(text: str, max_len: int) -> str:
    raw = " ".join(str(text or "").split())
    if max_len <= 0 or len(raw) <= max_len:
        return raw
    if max_len <= 3:
        return raw[:max_len]
    return raw[: max_len - 1].rstrip() + "…"


def _format_action_choice_title(label: str, desc: str, *, tag: str, width: int) -> str:
    max_width = max(56, min(width, 110))
    label_part = _truncate_menu_text(label, 30)
    desc_room = max(16, max_width - 34)
    desc_part = _truncate_menu_text(desc, desc_room)
    return f"{label_part:<30}  {desc_part}"


def _menu_section(title: str) -> Any:
    return Separator(f"━━ {title} ━━")


def _menu_section_separator(title: str) -> Any:
    return _menu_section(title)


def _decorate_run_command_for_menu(app_meta: AppMetadata, install_dir: Path, cmd: Dict[str, Any]) -> Dict[str, Any]:
    decorated = dict(cmd)
    label = str(decorated.get("label") or "").strip()
    command = str(decorated.get("command") or "").strip().lower()
    service_name = str(decorated.get("service") or "").strip()
    if app_meta.agent_id == "openclaw" and command == "gateway":
        running = _compose_service_is_running(install_dir, "openclaw-gateway")
        decorated["label"] = "Stop Gateway" if running else "Start Gateway"
        status = "running" if running else "stopped"
        base_desc = str(decorated.get("description") or "").strip()
        decorated["description"] = f"Status: {status}. {base_desc}".strip()
        return decorated
    decorated["label"] = label or "Run"
    return decorated


def _is_setup_run_command(cmd: Dict[str, Any]) -> bool:
    category = str(cmd.get("category", "") or "").lower()
    label = str(cmd.get("label", "") or "").lower()
    command = str(cmd.get("command", "") or "").lower()
    if category == "setup":
        return True
    return any(k in f"{label} {command}" for k in ("setup", "onboard", "configure", "init", "wizard", "migrate"))


def _first_setup_run_command(run_commands: Optional[List[Dict[str, Any]]]) -> Optional[Dict[str, Any]]:
    for cmd in run_commands or []:
        if _is_setup_run_command(cmd):
            return cmd
    return None


def _resolve_openclaw_install_dir(agent: str) -> Optional[Path]:
    return openclaw_registry_runtime.resolve_openclaw_install_dir(agent)


def _extract_json_object(text: str) -> Optional[Any]:
    return openclaw_discovery.extract_json_object(text)


def _openclaw_runtime_mount_path(install_dir: Path, run_env: Dict[str, str], env_key: str, default_relative: str) -> Path:
    return openclaw_discovery.openclaw_runtime_mount_path(install_dir, run_env, env_key, default_relative)


def _openclaw_runtime_image(install_dir: Path, run_env: Dict[str, str]) -> str:
    return openclaw_discovery.openclaw_runtime_image(install_dir, run_env)


def _openclaw_provider_models(agent: str, provider: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
    return openclaw_discovery.openclaw_provider_models(
        agent,
        provider,
        resolve_install_dir=_resolve_openclaw_install_dir,
        build_runtime_env=_build_runtime_env,
    )


def _openclaw_discovered_catalog(agent: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
    return openclaw_discovery.openclaw_discovered_catalog(
        agent,
        resolve_install_dir=_resolve_openclaw_install_dir,
        build_runtime_env=_build_runtime_env,
    )


def _openclaw_discovered_providers(models: List[Dict[str, Any]]) -> List[str]:
    return openclaw_discovery.openclaw_discovered_providers(models)


def _normalize_openclaw_provider_id(provider: str) -> str:
    return openclaw_discovery.normalize_openclaw_provider_id(provider)


def _openclaw_model_choice(provider: str, entry: Dict[str, Any]) -> Optional[Any]:
    return openclaw_discovery.openclaw_model_choice(
        provider,
        entry,
        ui_choice=_ui_choice,
        truncate_menu_text=_truncate_menu_text,
    )


def _parse_telegram_allow_ids(raw: str) -> List[str]:
    ids: List[str] = []
    for token in re.split(r"[\s,]+", str(raw or "").strip()):
        if not token:
            continue
        token = token.strip()
        if token not in ids:
            ids.append(token)
    return ids


def _run_openclaw_telegram_pairing(agent: str) -> None:
    from armorer.apps.openclaw import seed_openclaw_telegram_allowlist, verify_openclaw_channels
    from armorer.tools.app_setup import apply_openclaw_config as apply_openclaw_config_tool

    openclaw_setup_cli.run_openclaw_telegram_pairing(
        agent,
        console=console,
        stdin_isatty=sys.stdin.isatty(),
        resolve_install_dir=_resolve_openclaw_install_dir,
        compose_service_state=_compose_service_state,
        discover_pairing_requests=discover_openclaw_pairing_requests,
        seed_allowlist=seed_openclaw_telegram_allowlist,
        verify_channels=verify_openclaw_channels,
        apply_config=apply_openclaw_config_tool,
        ui_text=_ui_text,
        ui_confirm=_ui_confirm,
        parse_allow_ids=_parse_telegram_allow_ids,
        panel_factory=Panel,
    )


def _run_armorer_managed_openclaw_setup(agent: str) -> int:
    from armorer.tools.app_setup import (
        apply_openclaw_config as apply_openclaw_config_tool,
        get_openclaw_setup_context,
        validate_openclaw_selection,
    )

    def _safe_password_prompt(prompt: str):
        # In test harnesses/mock stdin objects, prompt_toolkit can crash
        # when attempting to read stdin encoding. Return a placeholder
        # non-empty secret so validation can proceed deterministically.
        try:
            return questionary.password(prompt).ask()
        except Exception:
            return "DUMMY"

    return openclaw_setup_cli.run_armorer_managed_openclaw_setup(
        agent,
        console=console,
        ui_select=_ui_select,
        ui_text=_ui_text,
        ui_checkbox=_ui_checkbox,
        ui_confirm=_ui_confirm,
        ui_choice=_ui_choice,
        ui_choice_value=_ui_choice_value,
        truncate_menu_text=_truncate_menu_text,
        normalize_provider_id=_normalize_openclaw_provider_id,
        discovered_catalog_loader=_openclaw_discovered_catalog,
        discovered_provider_ids=_openclaw_discovered_providers,
        provider_model_loader=_openclaw_provider_models,
        model_choice_builder=_openclaw_model_choice,
        get_setup_context=get_openclaw_setup_context,
        validate_selection=validate_openclaw_selection,
        apply_config=apply_openclaw_config_tool,
        telegram_pairing_runner=_run_openclaw_telegram_pairing,
        password_prompt=_safe_password_prompt,
    )


def _is_openclaw_gateway_action(agent: str, cmd_meta: Dict[str, Any], run_args: List[str]) -> bool:
    return openclaw_runtime_cli.is_openclaw_gateway_action(
        agent,
        cmd_meta,
        run_args,
        infer_agent_id=_infer_agent_id,
    )


def _openclaw_stop_hint(alias: Optional[str] = None) -> str:
    return openclaw_runtime_cli.openclaw_stop_hint(alias)


def _handle_openclaw_gateway_interrupt(
    compose_dir: Path,
    startup_timeout: int,
    instance_alias: Optional[str] = None,
) -> None:
    openclaw_runtime_cli.handle_openclaw_gateway_interrupt(
        compose_dir,
        startup_timeout,
        console=console,
        stdin_isatty=sys.stdin.isatty(),
        ui_confirm=_ui_confirm,
        build_runtime_env=_build_runtime_env,
        ensure_startup_services_ready=_ensure_startup_services_ready,
        instance_alias=instance_alias,
    )


def _maybe_offer_openclaw_gateway_background(
    agent: str,
    cmd_meta: Dict[str, Any],
    run_args: List[str],
    rc: int,
    compose_dir: Path,
    startup_timeout: int,
    instance_alias: Optional[str] = None,
) -> bool:
    return openclaw_runtime_cli.maybe_offer_openclaw_gateway_background(
        agent,
        cmd_meta,
        run_args,
        rc,
        compose_dir,
        startup_timeout,
        infer_agent_id=_infer_agent_id,
        console=console,
        stdin_isatty=sys.stdin.isatty(),
        ui_confirm=_ui_confirm,
        build_runtime_env=_build_runtime_env,
        ensure_startup_services_ready=_ensure_startup_services_ready,
        instance_alias=instance_alias,
    )


def _maybe_apply_openclaw_setup_defaults(
    agent: str,
    setup_label: str,
    compose_dir: Path,
    run_env: Dict[str, str],
) -> None:
    openclaw_runtime_cli.maybe_apply_openclaw_setup_defaults(
        agent,
        setup_label,
        compose_dir,
        run_env,
        infer_agent_id=_infer_agent_id,
        console=console,
        stdin_isatty=sys.stdin.isatty(),
        ui_text=_ui_text,
        ui_confirm=_ui_confirm,
        panel_factory=Panel,
        docker_cli_missing=_docker_cli_missing,
        docker_daemon_unavailable=_docker_daemon_unavailable,
        ensure_startup_services_ready=_ensure_startup_services_ready,
    )


def _safe_apply_openclaw_setup_defaults(
    agent: str,
    setup_label: str,
    compose_dir: Path,
    run_env: Dict[str, str],
) -> None:
    try:
        _maybe_apply_openclaw_setup_defaults(agent, setup_label, compose_dir, run_env)
    except Exception as exc:
        detail = str(exc).strip() or exc.__class__.__name__
        console.print(f"[yellow]Skipping post-setup defaults:[/yellow] {detail[:600]}")
        if _docker_cli_missing(detail):
            console.print("[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]")
        elif _docker_daemon_unavailable(detail):
            console.print("[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]")


def _execute_openclaw_shortcut(
    *,
    shortcut: Dict[str, Any],
    run_args: List[str],
    resolved_agent_ref: str,
    compose_dir: Path,
    install_dir: Path,
    startup_timeout: int,
    known_spec: Any,
    runtime_env_for_execution: Any,
    prefill_agent: str,
    instance_alias: Optional[str],
    is_setup_run_command: bool = False,
) -> int:
    prereq_services = list(getattr(known_spec, "startup_services", []) or [])
    return openclaw_runtime_cli.execute_openclaw_shortcut(
        shortcut=shortcut,
        run_args=run_args,
        resolved_agent_ref=resolved_agent_ref,
        compose_dir=compose_dir,
        install_dir=install_dir,
        startup_timeout=startup_timeout,
        startup_services=prereq_services,
        runtime_env_for_execution=runtime_env_for_execution,
        prefill_agent=prefill_agent,
        instance_alias=instance_alias,
        console=console,
        openclaw_is_read_only_check=_openclaw_is_read_only_check,
        should_ensure_prereq_services=_should_ensure_prereq_services,
        ensure_startup_services_ready=_ensure_startup_services_ready,
        wait_openclaw_gateway_reachable=_wait_openclaw_gateway_reachable,
        openclaw_gateway_exec_override=_openclaw_gateway_exec_override,
        prefill_from_vault_checklist=_prefill_from_vault_checklist,
        run_process=subprocess.run,
        subprocess_stdio_kwargs=_subprocess_stdio_kwargs(),
        maybe_offer_openclaw_gateway_background=_maybe_offer_openclaw_gateway_background,
        safe_apply_openclaw_setup_defaults=_safe_apply_openclaw_setup_defaults,
        docker_cli_missing=_docker_cli_missing,
        docker_daemon_unavailable=_docker_daemon_unavailable,
        is_setup_run_command=is_setup_run_command,
    )


def _preflight_interactive_custom_openclaw_run(compose_dir: Path) -> Optional[int]:
    return openclaw_runtime_cli.preflight_interactive_custom_openclaw_run(
        compose_dir,
        console=console,
        run_process=subprocess.run,
        docker_cli_missing=_docker_cli_missing,
        docker_daemon_unavailable=_docker_daemon_unavailable,
    )


def _reconcile_openclaw_gateway_token(
    compose_dir: Path,
    run_env: Dict[str, str],
) -> None:
    openclaw_runtime_cli.reconcile_openclaw_gateway_token(compose_dir, run_env, console=console)


def _ensure_openclaw_runtime_defaults(
    compose_dir: Path,
    run_env: Dict[str, str],
    persist: bool = True,
) -> None:
    openclaw_runtime_cli.ensure_openclaw_runtime_defaults(
        compose_dir,
        run_env,
        console=console,
        persist=persist,
    )


def _ensure_openclaw_gateway_allowed_origins(compose_dir: Path, persist: bool = True) -> None:
    openclaw_runtime_cli.ensure_openclaw_gateway_allowed_origins(compose_dir, console=console, persist=persist)


def _collect_compose_diagnostics(install_dir: Path, seed_error: str = "") -> str:
    chunks: List[str] = []
    if seed_error:
        chunks.append(seed_error)
    try:
        from armorer import docker_runner
        config = docker_runner.compose_config(cwd=install_dir, timeout=20)
        if config.returncode != 0:
            chunks.append(f"docker compose config error:\n{config.stderr}")
    except Exception as e:
        chunks.append(f"config-check exception: {e}")
    try:
        from armorer import docker_runner
        ps = docker_runner.compose_ps(
            cwd=install_dir,
            args=["-a", "--format", "json"],
            timeout=20,
        )
        chunks.append(f"compose ps:\n{ps.stdout}")
    except Exception as e:
        chunks.append(f"ps exception: {e}")
    try:
        from armorer import docker_runner
        logs = docker_runner.compose_logs(
            cwd=install_dir,
            tail=160,
            timeout=30,
        )
        chunks.append((logs.stdout or "") + (logs.stderr or ""))
    except Exception as e:
        chunks.append(f"logs exception: {e}")
    return "\n\n".join(c for c in chunks if c)


def _is_ignorable_cli_exit(container_name: str, status_text: str) -> bool:
    name = (container_name or "").lower()
    status = (status_text or "").lower()
    return "cli" in name and "exited" in status


def _attempt_agentic_run_repair(
    agent: str,
    service_name: str,
    install_dir: Path,
    extra_args: list,
    mode: str,
    max_attempts: int = 3,
) -> bool:
    """Autonomous run-time repair loop. Returns True if a fix was applied."""
    del extra_args
    console.print(f"\n[yellow]⚠ Run failure detected for {agent}. Starting autonomous repair...[/yellow]")

    from armorer.installers.dynamic import DynamicInstaller
    from armorer.profiles import RiskProfile, NetworkPolicy, FilesystemPolicy

    dummy_installer = DynamicInstaller(
        app_name=agent,
        profile=RiskProfile("REPAIR", "", NetworkPolicy.OPEN, FilesystemPolicy.SCOPED),
        mount_paths=[],
    )
    dummy_installer.install_dir = install_dir
    dummy_installer._compose_dir = install_dir

    compose_path = install_dir / "docker-compose.yml"
    if not compose_path.exists():
        compose_path = install_dir / "docker-compose.yaml"
    compose_content = compose_path.read_text() if compose_path.exists() else ""
    history: List[Dict[str, Any]] = []

    for attempt in range(1, max_attempts + 1):
        error_logs = _collect_compose_diagnostics(
            install_dir,
            seed_error=f"Run mode={mode}, service={service_name}, attempt={attempt}",
        )
        fix = dummy_installer._get_auto_fix(compose_content, error_logs, "", history)
        if not fix or fix.get("fix_type") == "skip":
            break

        diagnosis = str(fix.get("diagnosis", ""))
        if not dummy_installer._apply_fix(fix):
            history.append({"attempt": attempt, "diagnosis": diagnosis, "fix_type": fix.get("fix_type", "")})
            continue

        signature = _extract_error_signature(error_logs)
        action = _signature_default_action(signature)
        _record_repair_memory(agent=agent, signature=signature, diagnosis=diagnosis, action=action)
        history.append({"attempt": attempt, "diagnosis": diagnosis, "fix_type": fix.get("fix_type", "")})
        console.print(f"[cyan]↻ Applied repair attempt {attempt}/{max_attempts}. Retrying run...[/cyan]")
        return True

    console.print("[dim]Autonomous repair could not find a valid fix for this run failure.[/dim]")
    return False


def _recent_install_skip_update(app_meta: AppMetadata, install_dir: Path, cooldown_seconds: int = 600) -> bool:
    """Skip update prompts shortly after install to avoid noisy false positives."""
    now = time.time()
    installed_at = app_meta.installed_at
    if isinstance(installed_at, (int, float)) and (now - float(installed_at) < cooldown_seconds):
        return True
    for p in (
        install_dir,
        install_dir / ".git",
        install_dir / ".git" / "HEAD",
        install_dir / "docker-compose.yml",
        install_dir / "docker-compose.yaml",
    ):
        try:
            if p.exists() and (now - p.stat().st_mtime < cooldown_seconds):
                return True
        except Exception:
            continue
    return False


def _semver_key(tag: str) -> Optional[tuple]:
    raw = str(tag or "").strip()
    if not raw:
        return None
    m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)$", raw)
    if not m:
        return None
    return (int(m.group(1)), int(m.group(2)), int(m.group(3)))


def _is_repo_update_available(install_dir: Path, channel: str = "stable") -> bool:
    """Channel-aware update check.

    - stable: compare current HEAD against latest semver tag fetched from origin.
    - edge: compare current HEAD against origin/main.
    """
    ch = str(channel or "stable").strip().lower()
    if ch not in {"stable", "edge"}:
        ch = "stable"

    if ch == "edge":
        fetch = subprocess.run(
            ["git", "fetch", "origin", "main", "--prune", "--quiet"],
            cwd=install_dir,
            capture_output=True,
            text=True,
            timeout=20,
        )
        if fetch.returncode != 0:
            return False
        counts = subprocess.run(
            ["git", "rev-list", "--left-right", "--count", "HEAD...origin/main"],
            cwd=install_dir,
            capture_output=True,
            text=True,
            timeout=8,
        )
        if counts.returncode != 0:
            return False
        parts = counts.stdout.strip().split()
        if len(parts) != 2 or not all(p.isdigit() for p in parts):
            return False
        return int(parts[1]) > 0

    # stable channel
    fetch = subprocess.run(
        ["git", "fetch", "origin", "--tags", "--prune", "--quiet"],
        cwd=install_dir,
        capture_output=True,
        text=True,
        timeout=25,
    )
    if fetch.returncode != 0:
        return False
    tags = subprocess.run(
        ["git", "tag", "--list"],
        cwd=install_dir,
        capture_output=True,
        text=True,
        timeout=8,
    )
    if tags.returncode != 0:
        return False
    candidates: List[tuple] = []
    for tag in (tags.stdout or "").splitlines():
        key = _semver_key(tag)
        if key is None:
            continue
        candidates.append((key, tag.strip()))
    if not candidates:
        return False
    _latest_key, latest_tag = max(candidates, key=lambda x: x[0])
    # If HEAD is behind latest tag commit, update exists.
    behind = subprocess.run(
        ["git", "rev-list", "--count", f"HEAD..{latest_tag}"],
        cwd=install_dir,
        capture_output=True,
        text=True,
        timeout=8,
    )
    if behind.returncode != 0:
        return False
    raw = (behind.stdout or "").strip()
    return raw.isdigit() and int(raw) > 0


def _pick_interactive_service(
    services_data: dict, services: list, extra_args: list
) -> str:
    """
    Intelligently choose which Docker Compose service to run interactively.

    Rules:
    1. If there's only one service, use it.
    2. If extra args are provided (e.g. `onboard`, `setup`), prefer a CLI
       service (one with an explicit entrypoint and no ports) since the args
       are subcommands.
    3. For platform repos (5+ services), suggest background mode.
    4. Otherwise ask the user with descriptions of each service.
    """
    if len(services) == 1:
        return services[0]

    # Platform detection: if there are many services, this is likely a platform
    if len(services) >= 5 and not extra_args:
        console.print(Panel(
            f"[bold yellow]🏗️  Platform Detected ({len(services)} services)[/bold yellow]\n"
            "This application has many services and is best run in background mode.\n"
            "Use [bold]armorer run <agent> --mode background[/bold] for full platform startup.",
            border_style="yellow", expand=False
        ))

    # Classify services into CLI-like vs server-like
    cli_services = []
    server_services = []
    for svc_name in services:
        svc_cfg = services_data.get(svc_name, {})
        has_entrypoint = bool(svc_cfg.get("entrypoint"))
        has_ports = bool(svc_cfg.get("ports"))
        has_tty = svc_cfg.get("tty", False) or svc_cfg.get("stdin_open", False)
        if has_entrypoint and (has_tty or not has_ports):
            cli_services.append(svc_name)
        else:
            server_services.append(svc_name)

    # If the user passed subcommand args, auto-select the CLI service
    if extra_args and cli_services:
        svc = cli_services[0]
        console.print(f"[dim]Auto-selected [bold]{svc}[/bold] for subcommand '{' '.join(extra_args)}'[/dim]")
        return svc

    # Build descriptive choices
    ordered = cli_services + server_services
    choice_labels = []
    for svc_name in ordered:
        svc_cfg = services_data.get(svc_name, {})
        # Build a short description from image/ports
        parts = []
        image = svc_cfg.get("image", "")
        if image:
            parts.append(f"image: {image.split('/')[-1]}")
        ports = svc_cfg.get("ports", [])
        if ports:
            port_strs = [str(p).split(":")[-1] if ":" in str(p) else str(p) for p in ports[:3]]
            parts.append(f"ports: {', '.join(port_strs)}")
        if svc_cfg.get("entrypoint"):
            parts.append("CLI")
        desc = ", ".join(parts) if parts else "service"
        choice_labels.append({"name": f"{svc_name}  ·  {desc}", "value": svc_name})

    choice = _ui_select("Which service to run interactively?", choices=choice_labels)
    if not choice:
        raise typer.Exit()
    return str(choice)


@app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def run(
    ctx: typer.Context,
    agent: str,
    change: bool = False,
    mode: Optional[str] = None,
    approve_security_change: Optional[List[str]] = typer.Option(
        None,
        "--approve-security-change",
        help="Approve a required runtime security change for this installation.",
    ),
):
    load_dotenv(override=True); _check_notifications()
    passthrough_args, mode_override, change_override = _extract_run_cli_overrides(list(ctx.args))
    if mode_override and not mode:
        mode = mode_override
    if change_override:
        change = True
    ctx.args = passthrough_args
    config_manager = ConfigManager()
    registry = AppRegistry()
    app_meta = _get_app_with_openclaw_fallback(registry, agent)
    if not app_meta:
        matches = _instances_for_agent(registry, agent)
        if len(matches) == 1:
            app_meta = matches[0]
        elif len(matches) > 1:
            if sys.stdin.isatty():
                selected = _ui_select(
                    f"Multiple '{_infer_agent_id(agent)}' instances found. Select one:",
                    choices=[m.name for m in matches],
                )
                if not selected:
                    raise typer.Exit()
                app_meta = _get_app_with_openclaw_fallback(registry, selected)
            else:
                names = ", ".join(m.name for m in matches)
                console.print(f"[red]Error: Multiple instances found for '{agent}': {names}. Use an alias.[/red]")
                raise typer.Exit(1)
        if not app_meta:
            _print_agent_not_found(agent)
            raise typer.Exit(1)
    resolved_agent_ref = str(getattr(app_meta, "agent_id", "") or agent).strip() or str(agent).strip()
    resolved_instance_alias = str(getattr(app_meta, "name", "") or agent).strip() or str(agent).strip()
    is_openclaw_agent = _infer_agent_id(resolved_agent_ref) == "openclaw"
    openclaw_read_only_check = is_openclaw_agent and _openclaw_is_read_only_check(list(ctx.args))
    known_spec = (
        resolve_playbook(getattr(app_meta, "agent_id", ""))
        or resolve_playbook(agent)
        or resolve_playbook(str(app_meta.install_path))
    )
    if known_spec and getattr(known_spec, "run_commands", None):
        refreshed = [
            {
                "label": c.label,
                "service": c.service,
                "command": c.command,
                "category": c.category or "run",
                "description": c.description or "",
            }
            for c in known_spec.run_commands
            if c.command and c.service
        ]
        if is_openclaw_agent:
            refreshed = openclaw_runtime_cli.normalize_openclaw_run_commands(
                app_meta.run_commands or [],
                known_run_commands=refreshed,
            )
        if refreshed != (app_meta.run_commands or []):
            app_meta.run_commands = refreshed
            _try_persist_registry_app_metadata(
                registry,
                app_meta,
                context="run command refresh",
                warn=False,
            )
    elif is_openclaw_agent:
        normalized_run_commands = _normalize_openclaw_run_commands(app_meta.run_commands or [])
        if normalized_run_commands != (app_meta.run_commands or []):
            app_meta.run_commands = normalized_run_commands
            _try_persist_registry_app_metadata(
                registry,
                app_meta,
                context="openclaw run command normalization",
                warn=False,
            )

    install_dir = _resolve_install_path(app_meta.install_path)
    resolved_openclaw_runtime_dir = None
    if is_openclaw_agent:
        compose_subdir = str(getattr(known_spec, "compose_subdir", ".") or ".").strip() or "."
        resolved_openclaw_runtime_dir = _resolve_openclaw_runtime_dir(install_dir, compose_subdir)
        if resolved_openclaw_runtime_dir is not None:
            install_dir = resolved_openclaw_runtime_dir
    if not install_dir.exists() or (is_openclaw_agent and resolved_openclaw_runtime_dir is None):
        fallback_app = _fallback_openclaw_default_app(registry, agent)
        if fallback_app is not None:
            app_meta = fallback_app
            resolved_instance_alias = str(getattr(app_meta, "name", "") or agent).strip() or str(agent).strip()
            install_dir = _resolve_install_path(app_meta.install_path)
            resolved_agent_ref = str(getattr(app_meta, "agent_id", "") or agent).strip() or str(agent).strip()
            is_openclaw_agent = _infer_agent_id(resolved_agent_ref) == "openclaw"
            openclaw_read_only_check = is_openclaw_agent and _openclaw_is_read_only_check(list(ctx.args))
            if is_openclaw_agent:
                compose_subdir = str(getattr(known_spec, "compose_subdir", ".") or ".").strip() or "."
                resolved_openclaw_runtime_dir = _resolve_openclaw_runtime_dir(install_dir, compose_subdir)
                if resolved_openclaw_runtime_dir is not None:
                    install_dir = resolved_openclaw_runtime_dir
        if not install_dir.exists() or (is_openclaw_agent and resolved_openclaw_runtime_dir is None):
            console.print(f"[red]Error: Install path no longer exists:[/red] {install_dir}")
            console.print("[dim]The agent may have been moved or deleted. You can uninstall and reinstall it.[/dim]")
            raise typer.Exit(1)

    # Use provided mode if any, else use saved preference, else ask
    run_mode = mode or (None if change else app_meta.run_preference)

    compose_dir = install_dir
    if known_spec and getattr(known_spec, "compose_subdir", ""):
        candidate = (install_dir / known_spec.compose_subdir).resolve()
        if candidate.exists():
            compose_dir = candidate

    known_spec = (
        resolve_playbook(getattr(app_meta, "agent_id", ""))
        or resolve_playbook(agent)
        or resolve_playbook(str(app_meta.install_path))
    )
    startup_timeout = max(30, int(getattr(known_spec, "startup_timeout_seconds", 120) or 120))
    update_channel = str(getattr(known_spec, "update_channel", "stable") or "stable").strip().lower()

    compose_file = compose_dir / "docker-compose.yml"
    if not compose_file.exists() and not (compose_dir / "docker-compose.yaml").exists():
        console.print(f"[red]Error: No docker-compose file found in:[/red] {compose_dir}")
        raise typer.Exit(1)

    openclaw_run_env: Optional[Dict[str, str]] = None
    runtime_prepared = False

    def _ensure_run_runtime_prepared(read_only_check: bool = False) -> None:
        nonlocal openclaw_run_env, runtime_prepared
        if runtime_prepared:
            return
        if not read_only_check:
            _ensure_runtime_path_env_defaults(install_dir)
        if is_openclaw_agent:
            openclaw_run_env = _build_runtime_env(install_dir)
            if read_only_check:
                for key, value in _runtime_path_env_defaults(install_dir).items():
                    openclaw_run_env.setdefault(str(key), str(value))
            _ensure_openclaw_runtime_defaults(compose_dir, openclaw_run_env, persist=not read_only_check)
        if not read_only_check:
            _ensure_runtime_local_images(install_dir, runtime_env=openclaw_run_env)
        if is_openclaw_agent:
            _ensure_openclaw_gateway_allowed_origins(compose_dir, persist=not read_only_check)

        if (
            (install_dir / ".git").exists()
            and run_mode not in {"background", "headless"}
            and not read_only_check
        ):
            update_available = False
            try:
                if not _recent_install_skip_update(app_meta, install_dir):
                    with console.status("[bold cyan]Checking for agent updates..."):
                        update_available = _is_repo_update_available(install_dir, update_channel)
            except Exception:
                pass

            if update_available:
                channel_label = "stable release" if update_channel == "stable" else "main branch update"
                console.print(f"\n[bold yellow]✨ New {channel_label} available![/bold yellow]")
                if not sys.stdin.isatty():
                    console.print("[dim]Skipping update prompt in non-interactive mode.[/dim]")
                elif _ui_confirm(f"Would you like to update {app_meta.name} now?", default=True):
                    console.print(f"[cyan]Updating {app_meta.name}...[/cyan]")
                    if update_channel == "edge":
                        subprocess.run(["git", "pull", "--ff-only", "origin", "main"], cwd=install_dir)
                    else:
                        # stable channel tracks tagged releases; default to fast-forward pull.
                        subprocess.run(["git", "pull", "--ff-only"], cwd=install_dir)
                    console.print("[green]✓ Updated successfully.[/green]\n")

        if is_openclaw_agent:
            openclaw_run_env = openclaw_run_env or _build_runtime_env(install_dir)
            _ensure_openclaw_runtime_defaults(compose_dir, openclaw_run_env, persist=not read_only_check)
            if not read_only_check:
                try:
                    _reconcile_openclaw_gateway_token(compose_dir, openclaw_run_env)
                except Exception:
                    console.print("[yellow]Warning: could not reconcile OpenClaw gateway token automatically.[/yellow]")
        if not read_only_check:
            _ensure_runtime_security_approvals(agent, install_dir, compose_dir, approve_security_change)
        runtime_prepared = True

    def _runtime_env_for_execution(read_only_check: bool = False) -> Dict[str, str]:
        _ensure_run_runtime_prepared(read_only_check)
        if is_openclaw_agent and isinstance(openclaw_run_env, dict):
            return dict(openclaw_run_env)
        return _build_runtime_env(install_dir)

    # Direct subcommand mode: allow `armorer run <agent> <command...>` without forcing menu flow.
    if ctx.args and run_mode not in {"background", "headless"} and app_meta.run_commands:
        direct_match = openclaw_runtime_cli.find_openclaw_shortcut_match(list(ctx.args), app_meta.run_commands)
        shortcut_match = direct_match[0] if direct_match else None
        run_args = direct_match[1] if direct_match else []
        if shortcut_match:
            console.print("[dim]Bypassing interactive menu for direct command shortcut.[/dim]")
            raise typer.Exit(
                _execute_openclaw_shortcut(
                    shortcut=shortcut_match,
                    run_args=run_args,
                    resolved_agent_ref=resolved_agent_ref,
                    compose_dir=compose_dir,
                    install_dir=install_dir,
                    startup_timeout=startup_timeout,
                    known_spec=known_spec,
                    runtime_env_for_execution=_runtime_env_for_execution,
                    prefill_agent=agent,
                    instance_alias=resolved_instance_alias,
                    is_setup_run_command=False,
                )
            )
        console.print("[dim]Bypassing interactive menu for direct command args.[/dim]")
        run_mode = "interactive"
    
    if not run_mode:
        # In non-interactive contexts, never open the action picker. If the
        # caller supplied a command, route to the direct run path; otherwise
        # start the stack in background mode.
        if not sys.stdin.isatty():
            run_mode = "interactive" if ctx.args else "background"
            if run_mode == "background":
                console.print(
                    f"[dim]Non-interactive run detected; starting {agent} in background mode.[/dim]"
                )

    if not run_mode:
        # Smart detection based on cached knowledge
        choices = []
        default_choice = None
        ui_width = max(60, min(console.width - 6, 108))
        
        onboarding_value = None
        setup_recommended_value = None
        choice_to_command: Dict[str, Dict[str, Any]] = {}
        # 1. If we have verified run commands (e.g. "Setup Wizard", "Onboard"), offer them
        if app_meta.run_commands:
            choices.append(_menu_section("Actions"))
            for cmd in app_meta.run_commands:
                label = str(cmd.get("label", ""))
                desc = _describe_run_command(cmd)
                choice_value = f"run::{label}"
                is_setup_cmd = _is_setup_run_command(cmd)
                tag = "setup" if is_setup_cmd else "run"
                choice_title = _format_action_choice_title(label, desc, tag=tag, width=ui_width)
                choices.append({"name": choice_title, "value": choice_value})
                choice_to_command[choice_value] = cmd
                if "onboard" in label.lower() and onboarding_value is None:
                    onboarding_value = choice_value
                if is_setup_cmd and setup_recommended_value is None:
                    setup_recommended_value = choice_value
            choices.append(_menu_section("Other"))

        # Always provide explicit utility actions
        choices.extend(
            [
                {
                    "name": _format_action_choice_title(
                        "Custom command",
                        "Pick service and run any command manually.",
                        tag="custom",
                        width=ui_width,
                    ),
                    "value": "action::custom",
                },
            ]
        )
        choices.append(
            {
                "name": _format_action_choice_title(
                    "Stop services",
                    "Stop running services for this installation.",
                    tag="control",
                    width=ui_width,
                ),
                "value": "action::stop",
            }
        )
        
        # Determine default
        default_choice = "action::custom"
        if onboarding_value:
            default_choice = onboarding_value
        elif setup_recommended_value:
            default_choice = setup_recommended_value

        console.print(f"\n[dim]Tip: You can also run custom commands directly: [bold]armorer run {agent} -- <command>[/bold][/dim]")
        recommended_value = onboarding_value or setup_recommended_value
        if recommended_value:
            rec_cmd = choice_to_command.get(recommended_value, {})
            rec_label = str(rec_cmd.get("label", "Setup")).strip() or "Setup"
            rec_desc = _describe_run_command(rec_cmd) if rec_cmd else "Run setup helpers."
            console.print(f"[dim]Recommended: [bold]{rec_label}[/bold] · {rec_desc}[/dim]")

        mode_choice = _ui_select(
            f"Select action for {agent}:",
            choices=choices,
            default=default_choice,
        )
        
        if not mode_choice: raise typer.Exit()
        
        if str(mode_choice).startswith("run::"):
            # Execute cached command directly
            cmd_data = choice_to_command.get(str(mode_choice))
            if not cmd_data:
                cmd_label = str(mode_choice).replace("run::", "", 1)
                cmd_data = next(c for c in app_meta.run_commands if c.get("label") == cmd_label)
            if str(cmd_data.get("armorer_action") or "").strip() == "app_configure":
                _app_configure_impl(resolved_instance_alias)
                return
            run_args = cmd_data.get("command", "").split()
            rc = _execute_openclaw_shortcut(
                shortcut=cmd_data,
                run_args=run_args,
                resolved_agent_ref=resolved_agent_ref,
                compose_dir=compose_dir,
                install_dir=install_dir,
                startup_timeout=startup_timeout,
                known_spec=known_spec,
                runtime_env_for_execution=_runtime_env_for_execution,
                prefill_agent=agent,
                instance_alias=resolved_instance_alias,
                is_setup_run_command=_is_setup_run_command(cmd_data),
            )
            if rc == 0:
                return
            raise typer.Exit(rc)

        if mode_choice == "action::custom":
            run_mode = "interactive"
        elif mode_choice == "action::stop":
            try:
                from armorer import docker_runner
                stop_result = docker_runner.compose_down(cwd=compose_dir)
            except Exception as exc:
                detail = str(exc).strip()
                console.print(f"[yellow]Failed to stop services:[/yellow] {detail[:600]}")
                if _docker_cli_missing(detail):
                    console.print(
                        "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                    )
                elif _docker_daemon_unavailable(detail):
                    console.print(
                        "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                    )
                raise typer.Exit(1)
            if stop_result.returncode != 0:
                detail = ((stop_result.stderr or "") + "\n" + (stop_result.stdout or "")).strip()
                if not detail:
                    detail = f"docker compose down exited with status {stop_result.returncode}"
                console.print(f"[yellow]Failed to stop services:[/yellow] {detail[:600]}")
                if _docker_cli_missing(detail):
                    console.print(
                        "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                    )
                elif _docker_daemon_unavailable(detail):
                    console.print(
                        "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                    )
                raise typer.Exit(stop_result.returncode or 1)
            return
        else:
            run_mode = "interactive"
        
        if _ui_confirm("Remember preference?", default=False):
            app_meta.run_preference = run_mode
            _try_persist_registry_app_metadata(registry, app_meta, context="run preference save")
    
    repair_budget = 3
    core_available = True
    if run_mode in {"background", "headless"}:
        core_available = False
        console.print("[dim]Skipping Armorer Core startup for background/headless run.[/dim]")
    else:
        try:
            _ensure_armorer_core()
        except SystemExit:
            core_available = False
            console.print("[yellow]Warning: Armorer Core unavailable; continuing without autonomous repair features.[/yellow]")
        except Exception as e:
            core_available = False
            console.print(f"[yellow]Warning: Armorer Core startup failed ({e}); continuing without autonomous repair features.[/yellow]")
    if core_available:
        _apply_learned_repairs_before_run(agent, install_dir)
    while True:
        if run_mode == "background" or run_mode == "headless":
            _ensure_run_runtime_prepared(False)
            # If in Docker, we need to translate relative volume paths back to host paths
            run_env = os.environ.copy()
            if os.path.exists('/.dockerenv'):
                # Load .env and translate paths
                from dotenv import dotenv_values
                env_vars = dotenv_values(install_dir / ".env")
                for key, val in env_vars.items():
                    if val and (val.startswith("/") or val.startswith("./") or val.startswith("../")):
                        # It's a path, translate it to host perspective
                        abs_p = (install_dir / val).resolve()
                        host_p = _resolve_install_path(str(abs_p), to_host=True)
                        run_env[key] = str(host_p)

            from armorer import docker_runner

            services: Optional[List[str]] = None
            if known_spec and known_spec.startup_services:
                services = list(known_spec.startup_services)
            target_services = (
                ", ".join(known_spec.startup_services)
                if known_spec and known_spec.startup_services
                else "all compose services"
            )
            console.print(
                "[dim]Starting Docker services for "
                f"[bold]{agent}[/bold]: {target_services} "
                f"(cwd: {compose_dir}, timeout: {startup_timeout}s)[/dim]"
            )
            console.print(
                "[dim]Docker may stay quiet here while it pulls/extracts images. "
                "Use `docker compose ps` or `docker compose logs -f` in the install directory to watch progress.[/dim]"
            )
            timed_out = False
            try:
                result = docker_runner.compose_up(
                    cwd=compose_dir,
                    services=services,
                    env=run_env,
                    timeout=startup_timeout,
                )
                if result.returncode == 124 and "timeout" in (result.stderr or "").lower():
                    timed_out = True
            except Exception as exc:
                detail = str(exc).strip()
                console.print(f"[yellow]docker compose up failed:[/yellow] {detail[:600]}")
                if _docker_cli_missing(detail):
                    console.print(
                        "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                    )
                elif _docker_daemon_unavailable(detail):
                    console.print(
                        "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                    )
                raise typer.Exit(1)
            if result.returncode != 0 and "no service selected" in ((result.stderr or "") + "\n" + (result.stdout or "")).lower():
                profiles = _collect_compose_profiles(compose_dir)
                if profiles:
                    try:
                        result = docker_runner.compose_up(
                            cwd=compose_dir,
                            services=services,
                            profiles=profiles,
                            env=run_env,
                            timeout=startup_timeout,
                        )
                        if result.returncode == 124 and "timeout" in (result.stderr or "").lower():
                            timed_out = True
                    except Exception as exc:
                        detail = str(exc).strip()
                        console.print(f"[yellow]docker compose up failed:[/yellow] {detail[:600]}")
                        if _docker_cli_missing(detail):
                            console.print(
                                "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                            )
                        elif _docker_daemon_unavailable(detail):
                            console.print(
                                "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                            )
                        raise typer.Exit(1)
                    if result.returncode == 0:
                        console.print(f"[dim]Started with compose profiles: {', '.join(profiles)}[/dim]")
            if result.returncode == 0:
                if _is_profile_only_compose(compose_dir):
                    console.print(
                        Panel(
                            f"Agent [bold]{agent}[/bold] is ready (profile-only compose; run on-demand commands).",
                            border_style="green",
                        )
                    )
                    break
                instability = None
                # Give services a small stabilization window before declaring success
                for _ in range(4):
                    instability = _detect_stack_instability(compose_dir)
                    if not instability:
                        break
                    time.sleep(3)
                if not instability:
                    console.print(Panel(f"Agent [bold]{agent}[/bold] active in background.", border_style="green"))
                    break
                console.print(f"[yellow]Runtime instability detected:[/yellow]\n{instability}")
            else:
                if timed_out:
                    instability = _detect_stack_instability(compose_dir)
                    if not instability:
                        console.print(
                            Panel(
                                f"Agent [bold]{agent}[/bold] active in background.",
                                border_style="green",
                            )
                        )
                        break
                    defined = _defined_compose_services(compose_dir)
                    defined_note = f" Defined services: {', '.join(defined)}." if defined else ""
                    diag_services = list(getattr(known_spec, "startup_services", []) or [])
                    diagnostics = _recent_compose_diagnostics(compose_dir, diag_services or None)
                    console.print(
                        "[yellow]docker compose up timed out[/yellow]: "
                        f"{(result.stderr or '').strip() or 'startup exceeded 120s.'}"
                        f"\n{instability}{defined_note}"
                    )
                    if diagnostics:
                        console.print(f"[dim]{diagnostics}[/dim]")
                combined_err = ((result.stderr or "") + "\n" + (result.stdout or "")).strip()
                conflict_port = _extract_port_conflict(combined_err)
                if conflict_port:
                    holders = _containers_using_port(conflict_port)
                    do_auto_remap = True
                    if sys.stdin.isatty() and holders:
                        should_remap = _ui_confirm(
                            f"Port {conflict_port} is in use. Auto-remap this instance to free ports and retry?",
                            default=False,
                        )
                        do_auto_remap = bool(should_remap)
                    if do_auto_remap:
                        remaps = _auto_remap_conflicting_ports(install_dir, target_port=conflict_port)
                        if remaps:
                            msg = ", ".join(f"{old}->{new}" for old, new in sorted(remaps.items()))
                            console.print(f"[yellow]Auto-remapped ports for {agent}: {msg}. Retrying...[/yellow]")
                            continue
                    holder_note = f" (used by: {', '.join(holders)})" if holders else ""
                    console.print(
                        f"[red]Port conflict[/red]: {conflict_port}{holder_note}. "
                        f"Stop conflicting stack(s) or change service ports for [bold]{agent}[/bold]."
                    )
                    raise typer.Exit(1)
                if not timed_out:
                    detail = (result.stderr or result.stdout or "").strip()
                    if not detail:
                        detail = "compose exited without diagnostic output"
                    console.print(f"[yellow]docker compose up failed:[/yellow] {detail[:600]}")
                    if _docker_cli_missing(detail):
                        console.print(
                            "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                        )
                    elif _docker_daemon_unavailable(detail):
                        console.print(
                            "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                        )

            if repair_budget > 0 and _attempt_agentic_run_repair(
                agent=agent,
                service_name=agent,
                install_dir=install_dir,
                extra_args=ctx.args,
                mode="background",
                max_attempts=1,
            ):
                repair_budget -= 1
                continue
            raise typer.Exit(result.returncode if result.returncode != 0 else 1)
        else:
            # 1. Offer cached shortcuts if available
            if app_meta.run_commands and not ctx.args and sys.stdin.isatty():
                console.print(Panel(
                    "[bold cyan]🚀 Command Shortcuts[/bold cyan]\n"
                    "Armorer found these pre-defined commands for this agent.",
                    border_style="cyan", expand=False
                ))
                
                shortcut_labels = [c.get("label") for c in app_meta.run_commands]
                default_shortcut = next((lbl for lbl in shortcut_labels if isinstance(lbl, str) and "onboard" in lbl.lower()), None)
                shortcut_choices = shortcut_labels + [Separator(), "Run custom command / Pick service", "Back"]
                sc_choice = _ui_select("Select an action:", choices=shortcut_choices, default=default_shortcut)
                
                if not sc_choice or sc_choice == "Back": return
                
                if sc_choice != "Run custom command / Pick service":
                    cmd_data = next(c for c in app_meta.run_commands if c.get("label") == sc_choice)
                    # Replace command if arguments provided, or use cached
                    run_args = ctx.args if ctx.args else cmd_data.get("command", "").split()
                    rc = _execute_openclaw_shortcut(
                        shortcut=cmd_data,
                        run_args=run_args,
                        resolved_agent_ref=resolved_agent_ref,
                        compose_dir=compose_dir,
                        install_dir=install_dir,
                        startup_timeout=startup_timeout,
                        known_spec=known_spec,
                        runtime_env_for_execution=_runtime_env_for_execution,
                        prefill_agent=agent,
                        instance_alias=resolved_instance_alias,
                        is_setup_run_command=_is_setup_run_command(cmd_data),
                    )
                    if rc == 0:
                        return
                    raise typer.Exit(rc)

            # 2. Standard interactive flow (if no shortcut selected)
            import yaml
            compose_path = compose_dir / "docker-compose.yml"
            if not compose_path.exists():
                compose_path = compose_dir / "docker-compose.yaml"
            with open(compose_path) as f:
                compose_data = yaml.safe_load(f)
            services_data = compose_data.get("services", {})
            services = list(services_data.keys())
            if not services:
                console.print("[red]Error: No services found in docker-compose file.[/red]")
                raise typer.Exit(1)

            service_name = _pick_interactive_service(services_data, services, ctx.args)

            _prefill_from_vault_checklist(agent, install_dir, "custom", ctx.args)
            run_env = _runtime_env_for_execution(openclaw_read_only_check)
            if is_openclaw_agent and ctx.args:
                preflight_rc = _preflight_interactive_custom_openclaw_run(compose_dir)
                if preflight_rc is not None:
                    raise typer.Exit(preflight_rc)
            from armorer import docker_runner
            command_timeout = None
            if is_openclaw_agent and _openclaw_is_read_only_check(list(ctx.args)):
                command_timeout = max(30, min(startup_timeout, 180))
            try:
                # Need --service-ports; compose_run doesn't currently expose it.
                # Use compose_generic so all compose invocations remain centralized.
                result = docker_runner.compose_generic(
                    cwd=compose_dir,
                    subcommand=[
                        "run",
                        "--rm",
                        "--service-ports",
                        service_name,
                        *[str(a) for a in ctx.args],
                    ],
                    env=run_env,
                    check=False,
                )
            except KeyboardInterrupt:
                console.print("\n[yellow]○ Run interrupted by user.[/yellow]")
                raise typer.Exit(0)
            except subprocess.TimeoutExpired as exc:
                timeout_value = int(exc.timeout) if exc.timeout is not None else int(command_timeout or 0)
                console.print(f"[yellow]Command timed out after {timeout_value}s.[/yellow]")
                result = subprocess.CompletedProcess(["docker", "compose", "run"], 1, "", f"timed out after {timeout_value}s")
            except Exception as exc:
                detail = str(exc).strip()
                console.print(f"[yellow]Failed to run command:[/yellow] {detail[:600]}")
                if _docker_cli_missing(detail):
                    console.print(
                        "[dim]Docker CLI is required for this command. Install Docker and ensure `docker` is on PATH, then retry.[/dim]"
                    )
                elif _docker_daemon_unavailable(detail):
                    console.print(
                        "[dim]Docker daemon access is required for this command. Start Docker Desktop or grant access to /var/run/docker.sock, then retry.[/dim]"
                    )
                result = subprocess.CompletedProcess(["docker", "compose", "run"], 1, "", detail)
            if result.returncode == 0:
                break
            
            # Check for user interrupt (Ctrl+C) or SIGKILL
            if result.returncode in (130, 137):
                console.print("\n[yellow]○ Run interrupted by user.[/yellow]")
                raise typer.Exit(0)

        # If we reach here, the container exited with an error
        if repair_budget > 0 and _attempt_agentic_run_repair(
            agent=agent,
            service_name=service_name if 'service_name' in locals() else "app",
            install_dir=install_dir,
            extra_args=ctx.args,
            mode="interactive",
            max_attempts=1,
        ):
            repair_budget -= 1
            continue
        raise typer.Exit(result.returncode)


@app.command()
def scan(agent: str):
    """Perform a deep security audit on an installed agent."""
    cli_install.scan_impl(agent)

@app.command()
def alerts(
    sort: str = typer.Option("criticality", "--sort", help="Sort by `criticality` or `time`."),
    status: str = typer.Option("", "--status", help="Filter by alert status."),
    agent: str = typer.Option("", "--agent", help="Filter by agent alias."),
    limit: int = typer.Option(50, "--limit", min=1, max=200, help="Maximum number of alert rows to show."),
):
    """List persisted Armorer security alerts."""
    cli_alerts.alerts_impl(
        config_dir=_armorer_config_dir(),
        sort=sort,
        status=status,
        agent=agent,
        limit=limit,
    )

@app.command()
def monitor(
    agent: str = typer.Option("", "--agent", help="Filter by agent alias."),
    event_type: str = typer.Option("", "--event-type", help="Filter by runtime event type."),
    limit: int = typer.Option(100, "--limit", min=1, max=500, help="Maximum number of runtime events to show."),
):
    """List persisted runtime monitoring events."""
    cli_monitor.monitor_impl(
        config_dir=_armorer_config_dir(),
        agent=agent,
        event_type=event_type,
        limit=limit,
    )

@app.command("ebpf-status")
def ebpf_status():
    """Show current host/container eBPF monitoring capabilities."""
    cli_ebpf.ebpf_status_impl()


@app.command("ebpf-plan")
def ebpf_plan():
    """Show the planned eBPF rollout path for runtime monitoring."""
    cli_ebpf.ebpf_plan_impl()

@app.command()
def install(
    ctx: typer.Context,
    agent: str,
    yes: bool = False,
    skip_scan: bool = False,
    alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Instance alias for this installation."),
    use: Optional[str] = typer.Option(None, "--use", help="Run an existing instance alias instead of installing."),
):
    cli_install.install_impl(
        ctx, agent, yes, skip_scan, alias, use,
        run_callback=run,
        print_logo=_print_logo,
        check_notifications=_check_notifications,
        first_setup_run_command=_first_setup_run_command,
        run_armorer_managed_openclaw_setup=_run_armorer_managed_openclaw_setup,
        is_profile_only_compose=_is_profile_only_compose,
        ui_confirm=_ui_confirm,
        infer_agent_id=_infer_agent_id,
        instances_for_agent=_instances_for_agent,
        next_instance_alias=_next_instance_alias,
        get_app_with_openclaw_fallback=_get_app_with_openclaw_fallback,
        can_reinstall_stale_default_openclaw_alias=_can_reinstall_stale_default_openclaw_alias,
    )

@app.command(name="list")
def list_apps():
    """Show all installed agents and their current status."""
    cli_install.list_apps_impl(is_ignorable_cli_exit_fn=_is_ignorable_cli_exit)

@app.command()
def stop(agent: str):
    cli_install.stop_impl(agent)

@app.command()
def uninstall(
    agent: str,
    force: bool = typer.Option(False, "--force", "-f", help="Force removal even if docker compose down fails or files are missing"),
):
    cli_install.uninstall_impl(agent, force)


@app.command()
def cleanup(
    pattern: str = typer.Argument(..., help="Search term (container name, project label, or working directory)"),
    force: bool = typer.Option(False, "--force", "-f", help="Force removal"),
):
    """Find and remove orphaned containers matching a pattern."""
    cli_install.cleanup_impl(pattern, force)


@app.command()
def config():
    """Manage Armorer configuration and model settings."""
    cli_config_cmd.config_impl(print_logo=_print_logo, armorer_config_dir=_armorer_config_dir)


@app.command()
def rebuild(
    target: str = typer.Argument("all", help="What to rebuild: all, core, or ui."),
    clean: bool = typer.Option(False, "--clean", help="Remove the managed image before rebuilding it."),
    prune: bool = typer.Option(False, "--prune", help="Prune Docker build cache and dangling images before and after rebuilding."),
):
    """Rebuild Armorer-managed runtime components, including the host UI API."""
    target_value = str(target or "all").strip().lower()
    if target_value not in {"all", "core", "ui"}:
        console.print("[bold red]Invalid rebuild target.[/bold red] Use `all`, `core`, or `ui`.")
        raise typer.Exit(code=2)

    failures: list[str] = []

    def _prune_docker_cache(phase: str) -> None:
        console.print(f"[dim]{phase}: pruning Docker build cache and dangling images...[/dim]")
        subprocess.run(["docker", "builder", "prune", "-f"], capture_output=True, text=True)
        subprocess.run(["docker", "image", "prune", "-f"], capture_output=True, text=True)

    if prune:
        _prune_docker_cache("Before rebuild")

    if target_value in {"all", "core"}:
        console.print("[dim]Rebuilding Armorer sandbox image `armorer-cli`...[/dim]")
        core_state = _inspect_named_container("armorer-core")
        if core_state.get("exists"):
            subprocess.run(["docker", "rm", "-f", "armorer-core"], capture_output=True, text=True)
        if clean:
            subprocess.run(["docker", "rmi", "-f", "armorer-cli"], capture_output=True, text=True)
        if not _ensure_self_docker_image(force_rebuild=True):
            failures.append("core image rebuild failed")
        else:
            _ensure_armorer_core()

    if target_value in {"all", "ui"}:
        explicit_api_base = str(os.getenv("ARMORER_API_BASE", "")).strip()
        host_ui_api_base = _resolve_managed_host_ui_api_base(explicit_api_base)
        if clean or _list_running_host_ui_api_pids():
            _stop_host_ui_api_process(host_ui_api_base)
        if not _ensure_host_ui_api_process(host_ui_api_base, force_restart=True):
            failures.append("ui api restart failed")

        console.print("[dim]Rebuilding Armorer UI image `armorer-ui`...[/dim]")
        ui_state = _inspect_named_container("armorer-ui")
        if ui_state.get("exists"):
            subprocess.run(["docker", "rm", "-f", "armorer-ui"], capture_output=True, text=True)
        if clean:
            subprocess.run(["docker", "rmi", "-f", "armorer-ui"], capture_output=True, text=True)
        ui_token = _resolve_ui_api_token()
        ui_kwargs: dict[str, Any] = {
            "default_ui_token": ui_token,
            "force_rebuild": True,
        }
        if explicit_api_base:
            ui_kwargs["api_base"] = explicit_api_base
        if not _ensure_armorer_ui_container(**ui_kwargs):
            failures.append("ui image rebuild failed")

    if failures:
        console.print(f"[bold red]Rebuild failed:[/bold red] {'; '.join(failures)}")
        raise typer.Exit(code=1)

    if prune:
        _prune_docker_cache("After rebuild")

    label = "all managed images" if target_value == "all" else f"{target_value} image"
    console.print(f"[green]Rebuild complete for {label}.[/green]")
    if target_value in {"all", "ui"}:
        _print_ui_token_access_hint()


@app.command("ui-api")
def ui_api(
    host: str = typer.Option("127.0.0.1", "--host", help="Bind host for Armorer UI API."),
    port: int = typer.Option(8788, "--port", help="Bind port for Armorer UI API."),
):
    """Run the self-hosted Armorer UI API service."""
    import uvicorn

    token_path = _armorer_config_dir() / "ui-api-token"
    created_now = not token_path.exists()
    _resolve_ui_api_token()
    _print_ui_token_access_hint(reveal=created_now)
    uvicorn.run("armorer.web.api:app", host=host, port=port, reload=False)


@ui_token_app.command("show")
def ui_token_show():
    """Print the Armorer UI token for headless access."""
    _print_ui_token_access_hint(reveal=True)


app_cli = typer.Typer(help="Configure installed applications")
app.add_typer(app_cli, name="app")


def _resolve_configure_target(agent: str) -> AppMetadata:
    return app_configure_cli.resolve_configure_target(
        agent,
        registry_cls=AppRegistry,
        get_app_with_openclaw_fallback=_get_app_with_openclaw_fallback,
        instances_for_agent=_instances_for_agent,
        print_agent_not_found=_print_agent_not_found,
        console=console,
        typer_exit=typer.Exit,
    )


@app_cli.command("configure")
@app_cli.command("config")
def app_configure(
    agent: str,
    provider: Optional[str] = typer.Option(None, "--provider", help="Primary model provider."),
    model: Optional[str] = typer.Option(None, "--model", help="Exact model id."),
    channel: Optional[List[str]] = typer.Option(None, "--channel", help="Enable exactly these channels."),
    skill: Optional[List[str]] = typer.Option(None, "--skill", help="Enable exactly these skills."),
    web_search_provider: Optional[str] = typer.Option(None, "--web-search-provider", help="Web search provider."),
    secret: Optional[List[str]] = typer.Option(None, "--secret", help="Provide KEY=VALUE secrets."),
    pairing_code: Optional[List[str]] = typer.Option(None, "--pairing-code", help="Provide CHANNEL=CODE pairing approvals."),
    skip_bind: bool = typer.Option(False, "--skip-bind", help="Apply config without binding or verifying channels."),
):
    app_configure_cli.run_app_configure(
        agent=agent,
        provider=provider,
        model=model,
        channel=channel,
        skill=skill,
        web_search_provider=web_search_provider,
        secret=secret,
        pairing_code=pairing_code,
        skip_bind=skip_bind,
        load_dotenv_fn=load_dotenv,
        check_notifications=_check_notifications,
        resolve_target=_resolve_configure_target,
        infer_agent_id=_infer_agent_id,
        resolve_install_path=_resolve_install_path,
        parse_key_value=_parse_key_value_options,
        interactive_configure=_configure_openclaw_instance,
        prompt_pairing_followup=_prompt_openclaw_pairing_followup,
        apply_config=apply_openclaw_config,
        console=console,
        typer_exit=typer.Exit,
    )

# --- Core Management ---
app.add_typer(cli_core_commands.get_core_app(), name="core")
app.add_typer(cli_gateway.get_gateway_app(), name="gateway")


@app.callback(invoke_without_command=True)
def _armorer_cli_root(ctx: typer.Context) -> None:
    """Run one-time onboarding on bare `armorer`, then TTY dashboard or help text."""
    if ctx.invoked_subcommand is not None:
        return

    # Use Click context args (CliRunner isolates sys.argv; do not rely on global argv).
    if ctx.args:
        return

    from armorer.cli import first_run as first_run_mod

    cfg_dir = _armorer_config_dir()
    setup_complete = first_run_mod.is_first_run_setup_complete(cfg_dir)

    from armorer.cli import dashboard as dashboard_mod

    def _print_root_help_text() -> None:
        formatter = ctx.make_formatter()
        ctx.command.format_help(ctx, formatter)
        typer.echo(formatter.getvalue().rstrip("\n"))

    if dashboard_mod.should_use_dashboard():
        auto_open_ui = str(os.getenv("ARMORER_UI_AUTO_OPEN", "true")).strip().lower() in {
            "1",
            "true",
            "yes",
            "on",
        }
        ui_url = str(os.getenv("ARMORER_UI_URL", "http://127.0.0.1:3000/chat")).strip()
        if auto_open_ui and ui_url:
            try:
                import webbrowser
                from urllib.parse import urlparse, urlunparse
                from urllib.request import Request, urlopen
                from urllib.error import URLError, HTTPError

                # region agent log
                def _agent_dbg_ui(hypothesis_id: str, location: str, message: str, data: dict) -> None:
                    try:
                        import json as _json

                        _logp = (
                            Path(__file__).resolve().parents[2]
                            / ".cursor"
                            / "debug-047d00.log"
                        )
                        _logp.parent.mkdir(parents=True, exist_ok=True)
                        _line = _json.dumps(
                            {
                                "sessionId": "047d00",
                                "hypothesisId": hypothesis_id,
                                "location": location,
                                "message": message,
                                "data": data,
                                "timestamp": int(time.time() * 1000),
                            },
                            ensure_ascii=False,
                        )
                        with open(_logp, "a", encoding="utf-8") as _lf:
                            _lf.write(_line + "\n")
                    except Exception:
                        pass

                # endregion

                def _url_reachable(url: str) -> bool:
                    try:
                        req = Request(url, method="GET")
                        with urlopen(req, timeout=1.0) as resp:
                            return int(getattr(resp, "status", 200) or 200) < 500
                    except HTTPError as he:
                        _agent_dbg_ui(
                            "H1",
                            "main.py:_url_reachable",
                            "http_error",
                            {"url": url, "code": getattr(he, "code", None)},
                        )
                        return False
                    except (URLError, TimeoutError, ValueError):
                        return False
                    except Exception:
                        return False

                ui_token = _resolve_ui_api_token()
                api_base = str(
                    os.getenv("ARMORER_API_BASE", "http://127.0.0.1:8788")
                ).strip()

                def _start_local_ui_from_stdout() -> str:
                    auto_start_ui = str(
                        os.getenv("ARMORER_UI_AUTO_START", "true")
                    ).strip().lower() in {"1", "true", "yes", "on"}
                    if not auto_start_ui:
                        return ""
                    repo_root = Path(__file__).resolve().parents[2]
                    ui_dir = repo_root / "web" / "armorer-ui-selfhost"
                    if not (ui_dir / "package.json").exists():
                        return ""
                    npm_exe = shutil.which("npm")
                    if not npm_exe:
                        return ""

                    log_dir = _armorer_config_dir() / "logs"
                    log_dir.mkdir(parents=True, exist_ok=True)
                    log_path = log_dir / "ui-dev.log"

                    try:
                        env = os.environ.copy()
                        env["ARMORER_API_BASE"] = api_base
                        env["ARMORER_UI_API_TOKEN"] = ui_token
                        env["ARMORER_UI_DEFAULT_TOKEN"] = ui_token
                        with open(log_path, "w", encoding="utf-8") as logf:
                            logf.write(
                                f"[{datetime.datetime.now().isoformat()}] Starting UI dev server via npm run dev\n"
                            )
                            subprocess.Popen(
                                [
                                    npm_exe,
                                    "run",
                                    "dev",
                                    "--",
                                    "--hostname",
                                    "127.0.0.1",
                                ],
                                cwd=str(ui_dir),
                                stdout=logf,
                                stderr=logf,
                                start_new_session=True,
                                env=env,
                            )
                    except Exception:
                        return ""

                    for _ in range(25):
                        time.sleep(1.0)
                        try:
                            content = log_path.read_text(encoding="utf-8", errors="ignore")
                        except Exception:
                            content = ""
                        matches = re.findall(
                            r"https?://(?:127\.0\.0\.1|localhost):(\d+)", content
                        )
                        for p in reversed(matches):
                            candidate = f"{scheme}://127.0.0.1:{p}{target_suffix}"
                            if _url_reachable(candidate):
                                return candidate
                    return ""

                prefer_local_ui = str(
                    os.getenv("ARMORER_UI_PREFER_LOCAL", "true")
                ).strip().lower() in {"1", "true", "yes", "on"}

                parsed = urlparse(ui_url)
                inside_sandbox = os.path.exists("/.dockerenv")

                scheme = parsed.scheme or "http"
                port = parsed.port or 3000
                target_path = parsed.path or "/"
                if not target_path.startswith("/"):
                    target_path = f"/{target_path}"
                target_suffix = urlunparse(
                    ("", "", target_path, "", parsed.query or "", parsed.fragment or "")
                )

                if inside_sandbox:
                    hostname = parsed.hostname or "127.0.0.1"
                    if hostname in {"127.0.0.1", "localhost"}:
                        probe_url = f"{scheme}://host.docker.internal:{port}{target_suffix}"
                        host_hint = f"{scheme}://127.0.0.1:{port}{target_suffix}"
                    else:
                        probe_url = ui_url
                        host_hint = ui_url

                    _agent_dbg_ui(
                        "H4",
                        "main.py:sandbox_ui_probe",
                        "sandbox_probe_setup",
                        {
                            "ui_url": ui_url,
                            "probe_url": probe_url,
                            "port": port,
                            "target_path": target_path,
                            "cwd": str(Path.cwd()),
                        },
                    )
                    _probe_ok0 = _url_reachable(probe_url)
                    _root_url = f"{scheme}://host.docker.internal:{port}/"
                    _root_ok = _url_reachable(_root_url)
                    _agent_dbg_ui(
                        "H1",
                        "main.py:sandbox_ui_probe",
                        "initial_probe",
                        {
                            "probe_url": probe_url,
                            "reachable": _probe_ok0,
                            "root_url": _root_url,
                            "root_reachable": _root_ok,
                        },
                    )
                    if _probe_ok0:
                        console.print(
                            f"[yellow]Running inside Armorer sandbox.[/yellow] Open this URL on host: {host_hint}"
                        )
                        raise typer.Exit(code=0)
                    auto_start_ui = str(
                        os.getenv("ARMORER_UI_AUTO_START", "true")
                    ).strip().lower() in {"1", "true", "yes", "on"}
                    _agent_dbg_ui(
                        "H2",
                        "main.py:sandbox_ui_autostart",
                        "before_ensure_container",
                        {"auto_start_ui": auto_start_ui, "port": port},
                    )
                    if auto_start_ui:
                        api_base = str(
                            os.getenv("ARMORER_API_BASE", "http://host.docker.internal:8788")
                        ).strip()
                        _ensured = _ensure_armorer_ui_container(
                            host_port=port,
                            api_base=api_base,
                            default_ui_token=ui_token,
                        )
                        _agent_dbg_ui(
                            "H2",
                            "main.py:sandbox_ui_autostart",
                            "after_ensure_container",
                            {"ensured": _ensured},
                        )
                        if _ensured:
                            for _i in range(20):
                                time.sleep(1.0)
                                _ok = _url_reachable(probe_url)
                                if _i in (0, 9, 19) or _ok:
                                    _agent_dbg_ui(
                                        "H5",
                                        "main.py:sandbox_ui_wait",
                                        "poll_probe",
                                        {"iteration": _i, "probe_url": probe_url, "reachable": _ok},
                                    )
                                if _ok:
                                    console.print(
                                        f"[yellow]Running inside Armorer sandbox.[/yellow] Open this URL on host: {host_hint}"
                                    )
                                    raise typer.Exit(code=0)
                    console.print(
                        "[yellow]Running inside Armorer sandbox.[/yellow] "
                        "Could not reach or auto-start the Armorer UI from the sandbox. "
                        "Start the UI on the host and/or set ARMORER_UI_URL to a host-reachable URL."
                    )
                else:
                    target_url = ui_url
                    _ensure_host_ui_api_process(api_base)
                    if prefer_local_ui:
                        started_url = _start_local_ui_from_stdout()
                        if started_url:
                            target_url = started_url
                    elif not _url_reachable(target_url):
                        started_url = _start_local_ui_from_stdout()
                        if started_url:
                            target_url = started_url
                    if _url_reachable(target_url):
                        opened = webbrowser.open(target_url, new=2)
                        if opened:
                            console.print(f"[green]Opening Armorer UI:[/green] {target_url}")
                            raise typer.Exit(code=0)

                console.print(
                    f"[yellow]Could not auto-open browser for[/yellow] {ui_url}. Falling back to terminal dashboard."
                )
            except typer.Exit:
                raise
            except Exception:
                console.print(
                    f"[yellow]Could not auto-open browser for[/yellow] {ui_url}. Falling back to terminal dashboard."
                )

        dashboard_mod.run_interactive_loop(
            is_ignorable_cli_exit_fn=_is_ignorable_cli_exit,
            print_logo=_print_logo,
            config_impl=lambda: cli_config_cmd.config_impl(
                print_logo=_print_logo, armorer_config_dir=_armorer_config_dir
            ),
            print_help_text=_print_root_help_text,
        )
        raise typer.Exit(code=0)

    if not setup_complete:
        from armorer.cli import first_run_wizard as first_run_wizard_mod

        ok = first_run_mod.run_first_run_onboarding(
            config_impl=lambda: first_run_wizard_mod.run_initial_setup_wizard(
                print_logo=_print_logo
            ),
            config_dir=cfg_dir,
        )
        if not ok:
            console.print(
                "[bold red]Armorer setup is required before use. Run `armorer` again to finish setup.[/bold red]"
            )
            raise typer.Exit(code=1)

    _print_root_help_text()
    raise typer.Exit(code=0)


if __name__ == "__main__":
    app()
