#!/usr/bin/env bash set -euo pipefail IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest" INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}" INSTALL_DIR="${HOME}/.local/bin" if [[ ! -d "${HOME}/.local" ]]; then INSTALL_DIR="${HOME}/bin" fi BASH_PATH="${BASH:-$(command -v bash)}" if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >&2 exit 1 fi info() { printf '==> %s\n' "$*" } warn() { printf 'WARN: %s\n' "$*" >&2 } die() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } append_path_block() { local target_file="$1" local marker_start="# >>> headroom docker-native >>>" local marker_end="# <<< headroom docker-native <<<" local block="${marker_start} export PATH=\"${INSTALL_DIR}:\$PATH\" ${marker_end}" touch "${target_file}" if grep -Fq "${marker_start}" "${target_file}"; then return fi { printf '\n%s\n' "${block}" } >>"${target_file}" } write_wrapper() { local wrapper_path="${INSTALL_DIR}/headroom" { printf '#!%s\n\n' "${BASH_PATH}" printf 'HEADROOM_IMAGE_DEFAULT=%q\n' "${INSTALL_IMAGE}" cat <<'WRAPPER' set -euo pipefail HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-${HEADROOM_IMAGE_DEFAULT}}" HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}" HEADROOM_HOST_HOME="${HOME:?}" if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >&2 exit 1 fi warn() { printf 'WARN: %s\n' "$*" >&2 } die() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } detect_rtk_target() { local system local machine system="$(uname -s)" machine="$(uname -m)" case "${system}" in Darwin) if [[ "${machine}" == "arm64" ]]; then printf 'aarch64-apple-darwin' else printf 'x86_64-apple-darwin' fi ;; Linux) if [[ "${machine}" == "aarch64" ]]; then printf 'aarch64-unknown-linux-gnu' else printf 'x86_64-unknown-linux-musl' fi ;; *) die "Unsupported host platform for Docker-native wrapper: ${system}/${machine}" ;; esac } ensure_host_dirs() { mkdir -p \ "${HEADROOM_HOST_HOME}/.headroom" \ "${HEADROOM_HOST_HOME}/.claude" \ "${HEADROOM_HOST_HOME}/.codex" \ "${HEADROOM_HOST_HOME}/.gemini" } append_passthrough_envs() { local -n ref=$1 local name for name in $(compgen -e); do case "${name}" in HEADROOM_*|ANTHROPIC_*|OPENAI_*|GEMINI_*|AWS_*|AZURE_*|VERTEX_*|GOOGLE_*|GOOGLE_CLOUD_*|MISTRAL_*|GROQ_*|OPENROUTER_*|XAI_*|TOGETHER_*|COHERE_*|OLLAMA_*|LITELLM_*|OTEL_*|SUPABASE_*|QDRANT_*|NEO4J_*|LANGSMITH_*) ref+=(--env "${name}") ;; esac done } append_common_container_args() { local -n ref=$1 ensure_host_dirs ref+=(-w /workspace) ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}") ref+=(--env "PYTHONUNBUFFERED=1") # Canonical Headroom filesystem contract (issue #175) — forward into the # container so the proxy resolves state/config to the bind-mounted path. ref+=(--env "HEADROOM_WORKSPACE_DIR=${HEADROOM_CONTAINER_HOME}/.headroom") ref+=(--env "HEADROOM_CONFIG_DIR=${HEADROOM_CONTAINER_HOME}/.headroom/config") ref+=(-v "${PWD}:/workspace") ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom") ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude") ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex") ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini") if command -v id >/dev/null 2>&1; then ref+=(--user "$(id -u):$(id -g)") fi append_passthrough_envs "$1" } append_tty_args() { local -n ref=$1 if [[ -t 0 && -t 1 ]]; then ref+=(-it) elif [[ -t 0 ]]; then ref+=(-i) elif [[ -t 1 ]]; then ref+=(-t) fi } run_headroom() { local args=() args=(docker run --rm) append_tty_args args append_common_container_args args args+=(--entrypoint headroom "${HEADROOM_IMAGE}" "$@") "${args[@]}" } docker_container_exists() { local name="$1" docker ps --format '{{.Names}}' | grep -Fxq "${name}" } wait_for_proxy() { local container_name="$1" local port="$2" local attempt for attempt in $(seq 1 45); do if command -v curl >/dev/null 2>&1; then if curl --fail --silent "http://127.0.0.1:${port}/readyz" >/dev/null; then return 0 fi elif (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then return 0 fi if ! docker_container_exists "${container_name}"; then break fi sleep 1 done docker logs "${container_name}" >&2 || true return 1 } start_proxy_container() { local port="$1" shift local container_name="headroom-proxy-${port}-$$" local args=() args=(docker run -d --rm --name "${container_name}" -p "${port}:${port}") append_common_container_args args args+=("${HEADROOM_IMAGE}" --host 0.0.0.0 --port "${port}" "$@") "${args[@]}" >/dev/null if ! wait_for_proxy "${container_name}" "${port}"; then docker stop "${container_name}" >/dev/null 2>&1 || true die "Headroom proxy failed to start on port ${port}" fi printf '%s\n' "${container_name}" } stop_proxy_container() { local container_name="${1:-}" if [[ -n "${container_name}" ]]; then docker stop "${container_name}" >/dev/null 2>&1 || true fi } persistent_profile_root() { local profile="$1" validate_profile_name "${profile}" printf '%s/.headroom/deploy/%s\n' "${HEADROOM_HOST_HOME}" "${profile}" } persistent_state_path() { local profile="$1" printf '%s/docker-native.env\n' "$(persistent_profile_root "${profile}")" } persistent_manifest_path() { local profile="$1" printf '%s/manifest.json\n' "$(persistent_profile_root "${profile}")" } persistent_container_name() { local profile="$1" validate_profile_name "${profile}" printf 'headroom-%s\n' "${profile}" } validate_profile_name() { local profile="$1" [[ "${profile}" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid profile name '${profile}'" [[ "${profile}" != "." && "${profile}" != ".." ]] || die "Invalid profile name '${profile}'" } validate_port() { local port="$1" [[ "${port}" =~ ^[0-9]+$ ]] || die "Invalid port '${port}'" ((10#${port} >= 1 && 10#${port} <= 65535)) || die "Invalid port '${port}'" } validate_positive_integer() { local value="$1" [[ "${value}" =~ ^[0-9]+$ ]] || die "Invalid value '${value}'" ((10#${value} >= 1)) || die "Invalid value '${value}'" } require_option_value() { (($# >= 2)) || die "Option $1 requires a value" } json_escape() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//$'\n'/\\n}" printf '%s' "${value}" } json_array_from_args() { local first=1 local arg printf '[' for arg in "$@"; do if [[ "${first}" -eq 0 ]]; then printf ',' fi first=0 printf '"%s"' "$(json_escape "${arg}")" done printf ']' } append_persistent_container_args() { local -n ref=$1 ensure_host_dirs ref+=(--workdir "${HEADROOM_CONTAINER_HOME}") ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}") ref+=(--env "PYTHONUNBUFFERED=1") # Canonical Headroom filesystem contract (issue #175). ref+=(--env "HEADROOM_WORKSPACE_DIR=${HEADROOM_CONTAINER_HOME}/.headroom") ref+=(--env "HEADROOM_CONFIG_DIR=${HEADROOM_CONTAINER_HOME}/.headroom/config") ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom") ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude") ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex") ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini") if command -v id >/dev/null 2>&1; then ref+=(--user "$(id -u):$(id -g)") fi append_passthrough_envs "$1" } build_manifest_proxy_args() { local -n out_args=$1 local port="$2" local proxy_mode="$3" local backend="$4" local anyllm="$5" local region="$6" local memory_enabled="$7" local telemetry_enabled="$8" out_args=(--host 127.0.0.1 --port "${port}" --mode "${proxy_mode}" --backend "${backend}") if [[ "${telemetry_enabled}" -eq 0 ]]; then out_args+=(--no-telemetry) fi if [[ "${memory_enabled}" -eq 1 ]]; then out_args+=(--memory --memory-db-path "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db") fi if [[ -n "${anyllm}" ]]; then out_args+=(--anyllm-provider "${anyllm}") fi if [[ -n "${region}" ]]; then out_args+=(--region "${region}") fi } write_persistent_state() { local profile="$1" local image="$2" local port="$3" local backend="$4" local anyllm="$5" local region="$6" local proxy_mode="$7" local memory_enabled="$8" local telemetry_enabled="$9" local root root="$(persistent_profile_root "${profile}")" mkdir -p "${root}" { printf 'PROFILE=%s\n' "${profile}" printf 'IMAGE=%s\n' "${image}" printf 'PORT=%s\n' "${port}" printf 'BACKEND=%s\n' "${backend}" printf 'ANYLLM_PROVIDER=%s\n' "${anyllm}" printf 'REGION=%s\n' "${region}" printf 'PROXY_MODE=%s\n' "${proxy_mode}" printf 'MEMORY_ENABLED=%s\n' "${memory_enabled}" printf 'TELEMETRY_ENABLED=%s\n' "${telemetry_enabled}" printf 'CONTAINER_NAME=%s\n' "$(persistent_container_name "${profile}")" printf 'HEALTH_URL=%s\n' "http://127.0.0.1:${port}/readyz" } >"$(persistent_state_path "${profile}")" } write_persistent_manifest() { local profile="$1" local image="$2" local port="$3" local backend="$4" local anyllm="$5" local region="$6" local proxy_mode="$7" local memory_enabled="$8" local telemetry_enabled="$9" local -n proxy_args_ref=${10} local root local manifest_path local anyllm_json="null" local region_json="null" local memory_json="false" local telemetry_json="true" root="$(persistent_profile_root "${profile}")" manifest_path="$(persistent_manifest_path "${profile}")" mkdir -p "${root}" if [[ -n "${anyllm}" ]]; then anyllm_json="\"$(json_escape "${anyllm}")\"" fi if [[ -n "${region}" ]]; then region_json="\"$(json_escape "${region}")\"" fi if [[ "${memory_enabled}" -eq 1 ]]; then memory_json="true" fi if [[ "${telemetry_enabled}" -eq 0 ]]; then telemetry_json="false" fi cat >"${manifest_path}" </dev/null 2>&1 || true args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}") append_persistent_container_args args args+=( --env "HEADROOM_DEPLOYMENT_PROFILE=${profile}" --env "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" --env "HEADROOM_DEPLOYMENT_RUNTIME=docker" --env "HEADROOM_DEPLOYMENT_SUPERVISOR=none" --env "HEADROOM_DEPLOYMENT_SCOPE=user" ) args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}") "${args[@]}" >/dev/null if ! wait_for_proxy "${container_name}" "${port}"; then docker rm -f "${container_name}" >/dev/null 2>&1 || true die "Headroom persistent Docker deployment failed to start on port ${port}" fi write_persistent_state "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" write_persistent_manifest "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" proxy_args } stop_persistent_docker_install() { local profile="$1" local container_name load_persistent_state "${profile}" container_name="${CONTAINER_NAME}" docker stop "${container_name}" >/dev/null 2>&1 || true docker rm -f "${container_name}" >/dev/null 2>&1 || true } status_persistent_docker_install() { local profile="$1" local status="stopped" local ready="no" load_persistent_state "${profile}" if docker_container_exists "${CONTAINER_NAME}"; then status="running" if command -v curl >/dev/null 2>&1; then if curl --fail --silent "${HEALTH_URL}" >/dev/null; then ready="yes" fi elif (echo >/dev/tcp/127.0.0.1/"${PORT}") >/dev/null 2>&1; then ready="yes" fi fi printf 'Profile: %s\n' "${PROFILE}" printf 'Preset: persistent-docker\n' printf 'Runtime: docker\n' printf 'Supervisor: none\n' printf 'Port: %s\n' "${PORT}" printf 'Status: %s\n' "${status}" printf 'Ready: %s\n' "${ready}" printf 'Health URL: %s\n' "${HEALTH_URL}" } remove_persistent_docker_install() { local profile="$1" local root load_persistent_state "${profile}" docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true root="$(persistent_profile_root "${profile}")" rm -rf "${root}" } print_install_help() { cat <<'EOF' Usage: headroom install [OPTIONS] COMMAND [ARGS]... Manage persistent Docker-native Headroom deployments. The Docker-native wrapper currently supports the persistent-docker preset only. Use the Python-native `headroom install` command for persistent-service and persistent-task installs, or when you need provider/user/system config mutation. Options: -?, --help Show this message and exit. Commands: apply Install a persistent Docker deployment. remove Remove a persistent Docker deployment. restart Restart a persistent Docker deployment. start Start a persistent Docker deployment. status Show persistent Docker deployment status. stop Stop a persistent Docker deployment. EOF } print_install_apply_help() { cat <<'EOF' Usage: headroom install apply [OPTIONS] Install a persistent Docker deployment. Options: --preset [persistent-docker] Docker-native wrapper supports persistent-docker only. --runtime [docker] Docker-native wrapper supports runtime=docker only. --profile TEXT Deployment profile name. [default: default] -p, --port INTEGER Persistent proxy port. [default: 8787] --backend TEXT Proxy backend. [default: anthropic] --anyllm-provider TEXT Provider for any-llm backends. --region TEXT Cloud region for Bedrock / Vertex style backends. --mode TEXT Proxy optimization mode. [default: token] --memory Enable persistent memory in the runtime. --no-telemetry Disable anonymous telemetry in the runtime. --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest] -?, --help Show this message and exit. EOF } print_wrap_help() { cat <<'EOF' Usage: headroom wrap [OPTIONS] [-- ARGS...] Launch supported host tools through a Docker-native Headroom proxy. Supported commands: claude codex aider cursor openclaw Notes: - GitHub Copilot CLI wrapping is not supported by the Docker-native wrapper. - Use the Python-native CLI for unsupported wrap targets. EOF } parse_install_apply_args() { local -n out_profile=$1 local -n out_port=$2 local -n out_backend=$3 local -n out_anyllm=$4 local -n out_region=$5 local -n out_mode=$6 local -n out_memory=$7 local -n out_telemetry=$8 local -n out_image=$9 shift 9 out_profile="default" out_port=8787 out_backend="anthropic" out_anyllm="" out_region="" out_mode="token" out_memory=0 out_telemetry=1 out_image="${HEADROOM_IMAGE}" while (($#)); do case "$1" in --preset) require_option_value "$@" [[ "$2" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker" shift 2 ;; --preset=*) [[ "${1#*=}" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker" shift ;; --runtime) require_option_value "$@" [[ "$2" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker" shift 2 ;; --runtime=*) [[ "${1#*=}" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker" shift ;; --scope|--providers|--target) die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows" ;; --scope=*|--providers=*|--target=*) die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows" ;; --profile) require_option_value "$@" out_profile="$2" shift 2 ;; --profile=*) out_profile="${1#*=}" shift ;; --port|-p) require_option_value "$@" out_port="$2" shift 2 ;; --port=*|-p=*) out_port="${1#*=}" shift ;; --backend) require_option_value "$@" out_backend="$2" shift 2 ;; --backend=*) out_backend="${1#*=}" shift ;; --anyllm-provider) require_option_value "$@" out_anyllm="$2" shift 2 ;; --anyllm-provider=*) out_anyllm="${1#*=}" shift ;; --region) require_option_value "$@" out_region="$2" shift 2 ;; --region=*) out_region="${1#*=}" shift ;; --mode) require_option_value "$@" out_mode="$2" shift 2 ;; --mode=*) out_mode="${1#*=}" shift ;; --memory) out_memory=1 shift ;; --no-telemetry) out_telemetry=0 shift ;; --image) require_option_value "$@" out_image="$2" shift 2 ;; --image=*) out_image="${1#*=}" shift ;; --help|-?) print_install_apply_help exit 0 ;; *) die "Unsupported option for 'headroom install apply': $1" ;; esac done validate_port "${out_port}" } parse_install_profile_arg() { local -n out_profile=$1 shift out_profile="default" while (($#)); do case "$1" in --profile) require_option_value "$@" out_profile="$2" shift 2 ;; --profile=*) out_profile="${1#*=}" shift ;; --help|-?) print_install_help exit 0 ;; *) die "Unsupported option for 'headroom install': $1" ;; esac done } run_claude_rtk_init() { local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk" if [[ ! -x "${rtk_bin}" ]]; then warn "rtk was not installed at ${rtk_bin}; Claude hooks were not registered" return fi if ! "${rtk_bin}" init --global --auto-patch >/dev/null 2>&1; then warn "Failed to register Claude hooks with rtk; continuing without hook registration" fi } parse_wrap_args() { local -n out_known=$1 local -n out_host=$2 local -n out_port=$3 local -n out_no_rtk=$4 local -n out_no_proxy=$5 local -n out_learn=$6 local -n out_backend=$7 local -n out_anyllm=$8 local -n out_region=$9 shift 9 out_known=() out_host=() out_port=8787 out_no_rtk=0 out_no_proxy=0 out_learn=0 out_backend="" out_anyllm="" out_region="" while (($#)); do case "$1" in --) shift out_host+=("$@") break ;; --port|-p) require_option_value "$@" out_port="$2" validate_port "${out_port}" out_known+=("$1" "$2") shift 2 ;; --port=*) out_port="${1#*=}" validate_port "${out_port}" out_known+=("$1") shift ;; --no-rtk) out_no_rtk=1 out_known+=("$1") shift ;; --no-proxy) out_no_proxy=1 out_known+=("$1") shift ;; --learn) out_learn=1 out_known+=("$1") shift ;; --verbose|-v) out_known+=("$1") shift ;; --backend) require_option_value "$@" out_backend="$2" out_known+=("$1" "$2") shift 2 ;; --backend=*) out_backend="${1#*=}" out_known+=("$1") shift ;; --anyllm-provider) require_option_value "$@" out_anyllm="$2" out_known+=("$1" "$2") shift 2 ;; --anyllm-provider=*) out_anyllm="${1#*=}" out_known+=("$1") shift ;; --region) require_option_value "$@" out_region="$2" out_known+=("$1" "$2") shift 2 ;; --region=*) out_region="${1#*=}" out_known+=("$1") shift ;; *) out_host+=("$@") break ;; esac done } run_prepare_only() { local tool="$1" shift local args=() args=(docker run --rm) append_tty_args args append_common_container_args args args+=(--env "HEADROOM_RTK_TARGET=$(detect_rtk_target)") args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap "${tool}" --prepare-only "$@") "${args[@]}" } run_host_tool() { local binary="$1" shift command -v "${binary}" >/dev/null 2>&1 || die "'${binary}' not found in PATH" "${binary}" "$@" } contains_help_flag() { local arg for arg in "$@"; do if [[ "${arg}" == "--" ]]; then break fi if [[ "${arg}" == "--help" || "${arg}" == "-?" ]]; then return 0 fi done return 1 } parse_openclaw_wrap_args() { local -n out_plugin_path=$1 local -n out_plugin_spec=$2 local -n out_skip_build=$3 local -n out_copy=$4 local -n out_proxy_port=$5 local -n out_startup_timeout_ms=$6 local -n out_gateway_provider_ids=$7 local -n out_python_path=$8 local -n out_no_auto_start=$9 local -n out_no_restart=${10} local -n out_verbose=${11} shift 11 out_plugin_path="" out_plugin_spec="headroom-ai/openclaw" out_skip_build=0 out_copy=0 out_proxy_port=8787 out_startup_timeout_ms=20000 out_gateway_provider_ids=() out_python_path="" out_no_auto_start=0 out_no_restart=0 out_verbose=0 while (($#)); do case "$1" in --plugin-path) require_option_value "$@" out_plugin_path="$2" shift 2 ;; --plugin-path=*) out_plugin_path="${1#*=}" shift ;; --plugin-spec) require_option_value "$@" out_plugin_spec="$2" shift 2 ;; --plugin-spec=*) out_plugin_spec="${1#*=}" shift ;; --skip-build) out_skip_build=1 shift ;; --copy) out_copy=1 shift ;; --proxy-port) require_option_value "$@" out_proxy_port="$2" validate_port "${out_proxy_port}" shift 2 ;; --proxy-port=*) out_proxy_port="${1#*=}" validate_port "${out_proxy_port}" shift ;; --startup-timeout-ms) require_option_value "$@" out_startup_timeout_ms="$2" validate_positive_integer "${out_startup_timeout_ms}" shift 2 ;; --startup-timeout-ms=*) out_startup_timeout_ms="${1#*=}" validate_positive_integer "${out_startup_timeout_ms}" shift ;; --gateway-provider-id) require_option_value "$@" out_gateway_provider_ids+=("$2") shift 2 ;; --gateway-provider-id=*) out_gateway_provider_ids+=("${1#*=}") shift ;; --python-path) require_option_value "$@" out_python_path="$2" shift 2 ;; --python-path=*) out_python_path="${1#*=}" shift ;; --no-auto-start) out_no_auto_start=1 shift ;; --no-restart) out_no_restart=1 shift ;; --verbose|-v) out_verbose=1 shift ;; *) die "Unsupported option for 'headroom wrap openclaw': $1" ;; esac done } parse_openclaw_unwrap_args() { local -n out_no_restart=$1 local -n out_verbose=$2 shift 2 out_no_restart=0 out_verbose=0 while (($#)); do case "$1" in --no-restart) out_no_restart=1 shift ;; --verbose|-v) out_verbose=1 shift ;; *) die "Unsupported option for 'headroom unwrap openclaw': $1" ;; esac done } get_openclaw_existing_entry_json() { local output="" if output="$(openclaw config get plugins.entries.headroom 2>/dev/null)"; then printf '%s' "${output}" fi } prepare_openclaw_entry_json() { local existing_entry_json="$1" local proxy_port="$2" local startup_timeout_ms="$3" local python_path="$4" local no_auto_start="$5" shift 5 local gateway_provider_ids=("$@") local args=() args=(docker run --rm) append_common_container_args args args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap openclaw --prepare-only) args+=(--proxy-port "${proxy_port}" --startup-timeout-ms "${startup_timeout_ms}") if [[ -n "${existing_entry_json}" ]]; then args+=(--existing-entry-json "${existing_entry_json}") fi if [[ -n "${python_path}" ]]; then args+=(--python-path "${python_path}") fi if [[ "${no_auto_start}" -eq 1 ]]; then args+=(--no-auto-start) fi local provider_id for provider_id in "${gateway_provider_ids[@]}"; do args+=(--gateway-provider-id "${provider_id}") done "${args[@]}" } prepare_openclaw_unwrap_entry_json() { local existing_entry_json="$1" local args=() args=(docker run --rm) append_common_container_args args args+=(--entrypoint headroom "${HEADROOM_IMAGE}" unwrap openclaw --prepare-only) if [[ -n "${existing_entry_json}" ]]; then args+=(--existing-entry-json "${existing_entry_json}") fi "${args[@]}" } run_openclaw_checked() { local action="$1" shift local output="" if ! output="$("$@" 2>&1)"; then output="${output//$'\r'/}" die "${action} failed: ${output:-unknown error}" fi printf '%s' "${output//$'\r'/}" } run_openclaw_checked_in_dir() { local action="$1" local cwd="$2" shift 2 local output="" if ! output="$(cd "${cwd}" && "$@" 2>&1)"; then output="${output//$'\r'/}" die "${action} failed: ${output:-unknown error}" fi printf '%s' "${output//$'\r'/}" } resolve_openclaw_extensions_dir() { local config_output config_output="$(run_openclaw_checked "openclaw config file" openclaw config file)" local config_path config_path="$(printf '%s\n' "${config_output}" | tail -n 1)" [[ -n "${config_path}" ]] || die "Unable to resolve OpenClaw config path." printf '%s\n' "$(dirname "${config_path}")/extensions" } copy_openclaw_plugin_into_extensions() { local plugin_dir="$1" local dist_dir="${plugin_dir}/dist" local hook_shim_dir="${plugin_dir}/hook-shim" [[ -d "${dist_dir}" ]] || die "Plugin dist folder missing at ${dist_dir}. Build the plugin first." [[ -d "${hook_shim_dir}" ]] || die "Plugin hook-shim folder missing at ${hook_shim_dir}. Build the plugin first." local extensions_dir extensions_dir="$(resolve_openclaw_extensions_dir)" local target_dir="${extensions_dir}/headroom" mkdir -p "${target_dir}" rm -rf "${target_dir}/dist" "${target_dir}/hook-shim" cp -R "${dist_dir}" "${target_dir}/dist" cp -R "${hook_shim_dir}" "${target_dir}/hook-shim" local filename for filename in openclaw.plugin.json package.json README.md; do if [[ -f "${plugin_dir}/${filename}" ]]; then cp "${plugin_dir}/${filename}" "${target_dir}/${filename}" fi done printf '%s\n' "${target_dir}" } install_openclaw_plugin() { local plugin_path="$1" local plugin_spec="$2" local skip_build="$3" local copy_mode="$4" local verbose="$5" local local_source_mode=0 if [[ -n "${plugin_path}" ]]; then local_source_mode=1 [[ -d "${plugin_path}" ]] || die "Plugin path not found: ${plugin_path}." [[ -f "${plugin_path}/package.json" ]] || die "Invalid plugin path (missing package.json): ${plugin_path}" [[ -f "${plugin_path}/openclaw.plugin.json" ]] || die "Invalid plugin path (missing openclaw.plugin.json): ${plugin_path}" fi if [[ "${local_source_mode}" -eq 1 && "${skip_build}" -eq 0 ]]; then require_cmd npm info "Building OpenClaw plugin (npm install + npm run build)..." run_openclaw_checked_in_dir "npm install" "${plugin_path}" npm install >/dev/null run_openclaw_checked_in_dir "npm run build" "${plugin_path}" npm run build >/dev/null fi local install_output="" local install_status=0 set +e if [[ "${local_source_mode}" -eq 1 ]]; then if [[ "${copy_mode}" -eq 1 ]]; then install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_path}" 2>&1)" install_status=$? else install_output="$(cd "${plugin_path}" && openclaw plugins install --dangerously-force-unsafe-install --link . 2>&1)" install_status=$? fi else install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_spec}" 2>&1)" install_status=$? fi set -e install_output="${install_output//$'\r'/}" if [[ "${install_status}" -eq 0 ]]; then if [[ "${verbose}" -eq 1 && -n "${install_output}" ]]; then printf '%s\n' "${install_output}" fi return fi local lower_output="${install_output,,}" if [[ "${lower_output}" == *"plugin already exists"* ]]; then info "Plugin already installed; continuing with configuration/update steps." return fi if [[ "${lower_output}" == *"also not a valid hook pack"* && "${local_source_mode}" -eq 1 && "${copy_mode}" -eq 0 ]]; then info "OpenClaw linked-path install bug detected; applying extension-path fallback..." local target_dir target_dir="$(copy_openclaw_plugin_into_extensions "${plugin_path}")" info "Fallback plugin copy completed: ${target_dir}" return fi die "openclaw plugins install failed: ${install_output:-exit code ${install_status}}" } restart_or_start_openclaw_gateway() { local output="" if output="$(openclaw gateway restart 2>&1)"; then OPENCLAW_GATEWAY_ACTION="restarted" OPENCLAW_GATEWAY_OUTPUT="${output//$'\r'/}" return fi OPENCLAW_GATEWAY_OUTPUT="$(run_openclaw_checked "openclaw gateway start" openclaw gateway start)" OPENCLAW_GATEWAY_ACTION="started" } wrap_openclaw_host() { local plugin_path plugin_spec skip_build copy_mode proxy_port startup_timeout_ms python_path local no_auto_start no_restart verbose local gateway_provider_ids=() parse_openclaw_wrap_args \ plugin_path \ plugin_spec \ skip_build \ copy_mode \ proxy_port \ startup_timeout_ms \ gateway_provider_ids \ python_path \ no_auto_start \ no_restart \ verbose \ "$@" require_cmd openclaw local existing_entry_json="" existing_entry_json="$(get_openclaw_existing_entry_json)" local entry_json entry_json="$(prepare_openclaw_entry_json "${existing_entry_json}" "${proxy_port}" "${startup_timeout_ms}" "${python_path}" "${no_auto_start}" "${gateway_provider_ids[@]}")" printf '\n ╔═══════════════════════════════════════════════╗\n' printf ' ║ HEADROOM WRAP: OPENCLAW ║\n' printf ' ╚═══════════════════════════════════════════════╝\n\n' if [[ -n "${plugin_path}" ]]; then printf ' Plugin source: local (%s)\n' "${plugin_path}" else printf ' Plugin source: npm (%s)\n' "${plugin_spec}" fi printf ' Writing plugin configuration...\n' run_openclaw_checked \ "openclaw config set plugins.entries.headroom" \ openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null printf ' Installing OpenClaw plugin with required unsafe-install flag...\n' install_openclaw_plugin "${plugin_path}" "${plugin_spec}" "${skip_build}" "${copy_mode}" "${verbose}" run_openclaw_checked \ "openclaw config set plugins.slots.contextEngine" \ openclaw config set plugins.slots.contextEngine '"headroom"' --strict-json >/dev/null run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null if [[ "${no_restart}" -eq 1 ]]; then printf ' Skipping gateway restart (--no-restart).\n' printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.\n' else printf ' Applying plugin changes to OpenClaw gateway...\n' restart_or_start_openclaw_gateway printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}" if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}" fi fi local inspect_output="" inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)" if [[ "${verbose}" -eq 1 && -n "${inspect_output}" ]]; then printf '%s\n' "${inspect_output}" fi printf '\n✓ OpenClaw is configured to use Headroom context compression.\n' printf ' Plugin: headroom\n' printf ' Slot: plugins.slots.contextEngine = headroom\n\n' } unwrap_openclaw_host() { local no_restart verbose parse_openclaw_unwrap_args no_restart verbose "$@" require_cmd openclaw local existing_entry_json="" existing_entry_json="$(get_openclaw_existing_entry_json)" local entry_json entry_json="$(prepare_openclaw_unwrap_entry_json "${existing_entry_json}")" printf '\n ╔═══════════════════════════════════════════════╗\n' printf ' ║ HEADROOM UNWRAP: OPENCLAW ║\n' printf ' ╚═══════════════════════════════════════════════╝\n\n' printf ' Disabling Headroom plugin and removing engine mapping...\n' run_openclaw_checked \ "openclaw config set plugins.entries.headroom" \ openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null run_openclaw_checked \ "openclaw config set plugins.slots.contextEngine" \ openclaw config set plugins.slots.contextEngine '"legacy"' --strict-json >/dev/null run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null if [[ "${no_restart}" -eq 1 ]]; then printf ' Skipping gateway restart (--no-restart).\n' printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.\n' else printf ' Applying unwrap changes to OpenClaw gateway...\n' restart_or_start_openclaw_gateway printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}" if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}" fi fi if [[ "${verbose}" -eq 1 ]]; then local inspect_output="" inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)" if [[ -n "${inspect_output}" ]]; then printf '%s\n' "${inspect_output}" fi fi printf '\n✓ OpenClaw Headroom wrap removed.\n' printf ' Plugin: headroom (installed, disabled)\n' printf ' Slot: plugins.slots.contextEngine = legacy\n\n' } main() { require_cmd docker if (($# == 0)); then run_headroom --help return fi case "$1" in install) if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then print_install_help return fi local install_command="$2" shift 2 case "${install_command}" in apply) local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image "$@" start_persistent_docker_install "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" printf "Installed docker-native persistent deployment '%s' on port %s.\n" "${profile}" "${port}" ;; status) local profile parse_install_profile_arg profile "$@" status_persistent_docker_install "${profile}" ;; start) local profile parse_install_profile_arg profile "$@" load_persistent_state "${profile}" start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}" printf "Started docker-native persistent deployment '%s'.\n" "${profile}" ;; stop) local profile parse_install_profile_arg profile "$@" stop_persistent_docker_install "${profile}" printf "Stopped docker-native persistent deployment '%s'.\n" "${profile}" ;; restart) local profile parse_install_profile_arg profile "$@" load_persistent_state "${profile}" start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}" printf "Restarted docker-native persistent deployment '%s'.\n" "${profile}" ;; remove) local profile parse_install_profile_arg profile "$@" remove_persistent_docker_install "${profile}" printf "Removed docker-native persistent deployment '%s'.\n" "${profile}" ;; *) die "Unsupported install target: ${install_command}" ;; esac ;; wrap) if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then print_wrap_help return fi (($# >= 2)) || die "Usage: headroom wrap [...]" local tool="$2" shift 2 case "${tool}" in claude|codex|aider|cursor|openclaw) ;; *) die "Docker-native wrapper does not support 'wrap ${tool}'. Supported targets: claude, codex, aider, cursor, openclaw" ;; esac if [[ "${tool}" == "openclaw" ]]; then if contains_help_flag "$@"; then run_headroom wrap openclaw "$@" return fi wrap_openclaw_host "$@" return fi if contains_help_flag "$@"; then run_headroom wrap "${tool}" "$@" return fi local known_args host_args port no_rtk no_proxy learn backend anyllm region parse_wrap_args known_args host_args port no_rtk no_proxy learn backend anyllm region "$@" local proxy_args=() if [[ "${learn}" -eq 1 ]]; then proxy_args+=(--learn) fi if [[ -n "${backend}" ]]; then proxy_args+=(--backend "${backend}") fi if [[ -n "${anyllm}" ]]; then proxy_args+=(--anyllm-provider "${anyllm}") fi if [[ -n "${region}" ]]; then proxy_args+=(--region "${region}") fi local container_name="" if [[ "${no_proxy}" -eq 0 ]]; then container_name="$(start_proxy_container "${port}" "${proxy_args[@]}")" fi trap 'stop_proxy_container "${container_name}"' EXIT INT TERM local prep_args=("${known_args[@]}") if [[ "${no_proxy}" -eq 0 ]]; then prep_args+=(--no-proxy) fi run_prepare_only "${tool}" "${prep_args[@]}" case "${tool}" in claude) if [[ "${no_rtk}" -eq 0 ]]; then run_claude_rtk_init fi ANTHROPIC_BASE_URL="http://127.0.0.1:${port}" run_host_tool claude "${host_args[@]}" ;; codex) OPENAI_BASE_URL="http://127.0.0.1:${port}/v1" run_host_tool codex "${host_args[@]}" ;; aider) OPENAI_API_BASE="http://127.0.0.1:${port}/v1" \ ANTHROPIC_BASE_URL="http://127.0.0.1:${port}" \ run_host_tool aider "${host_args[@]}" ;; cursor) cat <= 2)) && [[ "$2" == "openclaw" ]]; then shift 2 if contains_help_flag "$@"; then run_headroom unwrap openclaw "$@" return fi unwrap_openclaw_host "$@" return fi run_headroom "$@" ;; proxy) shift local port=8787 local args=() args=(proxy) while (($#)); do case "$1" in --port|-p) require_option_value "$@" port="$2" validate_port "${port}" args+=("$1" "$2") shift 2 ;; --port=*) port="${1#*=}" validate_port "${port}" args+=("$1") shift ;; *) args+=("$1") shift ;; esac done local run_args=() run_args=(docker run --rm) append_tty_args run_args append_common_container_args run_args run_args+=(-p "${port}:${port}") run_args+=(--entrypoint headroom "${HEADROOM_IMAGE}" "${args[@]}") "${run_args[@]}" ;; *) run_headroom "$@" ;; esac } main "$@" WRAPPER } >"${wrapper_path}" chmod +x "${wrapper_path}" } main() { require_cmd docker docker version >/dev/null 2>&1 || die "Docker is installed but not available to the current user" mkdir -p "${INSTALL_DIR}" write_wrapper append_path_block "${HOME}/.bashrc" append_path_block "${HOME}/.zshrc" append_path_block "${HOME}/.profile" if [[ -n "${HEADROOM_DOCKER_IMAGE:-}" ]]; then if docker image inspect "${INSTALL_IMAGE}" >/dev/null 2>&1; then info "Using existing HEADROOM_DOCKER_IMAGE=${INSTALL_IMAGE}" else info "Pulling ${INSTALL_IMAGE}" docker pull "${INSTALL_IMAGE}" >/dev/null fi else info "Pulling ${IMAGE_DEFAULT}" docker pull "${IMAGE_DEFAULT}" >/dev/null fi cat <