From b9d42b36e7899dded3d49c26d280f6f1c18dceb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Le=C3=A3o?= Date: Sat, 4 Apr 2026 14:41:59 -0300 Subject: [PATCH 1/5] add openclaw migration script --- migration/migrate-openclaw.sh | 1064 +++++++++++++++++++++++++++++++++ 1 file changed, 1064 insertions(+) create mode 100755 migration/migrate-openclaw.sh diff --git a/migration/migrate-openclaw.sh b/migration/migrate-openclaw.sh new file mode 100755 index 0000000..03f83aa --- /dev/null +++ b/migration/migrate-openclaw.sh @@ -0,0 +1,1064 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────── +# migrate-openclaw.sh — Migrate an OpenClaw installation to Agent Zero +# ────────────────────────────────────────────────────────────────────── +# +# Usage: +# ./migrate-openclaw.sh [--include-auth-profiles] [OPENCLAW_DIR] [A0_USR_DIR] +# +# Defaults: +# OPENCLAW_DIR = ~/.openclaw +# A0_USR_DIR = /a0/usr +# +# What it migrates: +# 1. API keys (.env, config env refs, optional auth-profiles scan) +# 2. Agent Zero profiles generated from OpenClaw agents +# 3. Promptinclude files into a real Agent Zero workdir tree +# 4. Telegram bot config in the current Agent Zero plugin schema +# 5. Memory files into searchable knowledge roots +# 6. Skills with agent/global scope preserved +# +# Notes: +# - Agent Zero profiles are not equivalent to OpenClaw isolated agents. +# - Promptinclude files are written to usr/workdir/openclaw-migration//. +# To load them automatically, point Agent Zero workdir or project there. +# +# Requirements: +# - Python 3.8+ +# - Optional JSON5 parser: python `json5` module or node `json5` package +# +# ────────────────────────────────────────────────────────────────────── +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}ℹ${NC} $*"; } +success() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +error() { echo -e "${RED}✗${NC} $*"; } +header() { echo -e "\n${BOLD}═══ $* ═══${NC}"; } + +usage() { + cat <<'EOF' +Usage: + ./migrate-openclaw.sh [--include-auth-profiles] [OPENCLAW_DIR] [A0_USR_DIR] + +Options: + --include-auth-profiles Attempt to extract API-key-style secrets from auth-profiles.json + -h, --help Show this help text +EOF +} + +MIGRATED=0 +SKIPPED=0 +WARNINGS=0 +REPORT_INITIALIZED=0 + +report_line() { + if [[ "${REPORT_INITIALIZED}" == "1" ]]; then + printf '%s\n' "$*" >> "${MIGRATION_REPORT}" + fi +} + +log_migrated() { + MIGRATED=$((MIGRATED + 1)) + success "$*" + report_line "- Migrated: $*" +} + +log_skipped() { + SKIPPED=$((SKIPPED + 1)) + info "Skipped: $*" + report_line "- Skipped: $*" +} + +log_warning() { + WARNINGS=$((WARNINGS + 1)) + warn "$*" + report_line "- Warning: $*" +} + +INCLUDE_AUTH_PROFILES=0 +POSITIONAL_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --include-auth-profiles) + INCLUDE_AUTH_PROFILES=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + POSITIONAL_ARGS+=("$1") + shift + ;; + esac +done + +OPENCLAW_DIR="${POSITIONAL_ARGS[0]:-$HOME/.openclaw}" +A0_USR_DIR="${POSITIONAL_ARGS[1]:-/a0/usr}" + +OPENCLAW_DIR="${OPENCLAW_DIR/#\~/$HOME}" +A0_USR_DIR="${A0_USR_DIR/#\~/$HOME}" + +OPENCLAW_CONFIG="${OPENCLAW_DIR}/openclaw.json" +OPENCLAW_ENV="${OPENCLAW_DIR}/.env" +OPENCLAW_AUTH_PROFILES="${OPENCLAW_DIR}/auth-profiles.json" +A0_ENV="${A0_USR_DIR}/.env" +MIGRATION_LOG="${A0_USR_DIR}/openclaw-migration.log" +MIGRATION_REPORT="${A0_USR_DIR}/openclaw-migration-report.md" +MIGRATION_WORKDIR_ROOT="${A0_USR_DIR}/workdir/openclaw-migration" +KNOWLEDGE_DIR="${A0_USR_DIR}/knowledge/custom/openclaw" +MEMORY_KNOWLEDGE_DIR="${A0_USR_DIR}/knowledge/custom/openclaw-memory" + +KNOWN_ENV_KEYS=( + "OPENAI_API_KEY" + "ANTHROPIC_API_KEY" + "GEMINI_API_KEY" + "GOOGLE_API_KEY" + "OPENROUTER_API_KEY" + "GROQ_API_KEY" + "MISTRAL_API_KEY" + "DEEPSEEK_API_KEY" + "TOGETHER_API_KEY" + "PERPLEXITY_API_KEY" + "XAI_API_KEY" + "CEREBRAS_API_KEY" + "SAMBANOVA_API_KEY" +) + +sanitize_agent_id() { + local value="$1" + value="$(printf '%s' "${value}" | tr -cs 'A-Za-z0-9._-' '-')" + value="${value#-}" + value="${value%-}" + if [[ -z "${value}" ]]; then + value="agent" + fi + printf '%s' "${value}" +} + +map_env_key() { + case "$1" in + OPENAI_API_KEY) printf '%s' "API_KEY_OPENAI" ;; + ANTHROPIC_API_KEY) printf '%s' "API_KEY_ANTHROPIC" ;; + GEMINI_API_KEY|GOOGLE_API_KEY) printf '%s' "API_KEY_GOOGLE" ;; + OPENROUTER_API_KEY) printf '%s' "API_KEY_OPENROUTER" ;; + GROQ_API_KEY) printf '%s' "API_KEY_GROQ" ;; + MISTRAL_API_KEY) printf '%s' "API_KEY_MISTRAL" ;; + DEEPSEEK_API_KEY) printf '%s' "API_KEY_DEEPSEEK" ;; + TOGETHER_API_KEY) printf '%s' "API_KEY_TOGETHER" ;; + PERPLEXITY_API_KEY) printf '%s' "API_KEY_PERPLEXITYAI" ;; + XAI_API_KEY) printf '%s' "API_KEY_XAI" ;; + CEREBRAS_API_KEY) printf '%s' "API_KEY_CEREBRAS" ;; + SAMBANOVA_API_KEY) printf '%s' "API_KEY_SAMBANOVA" ;; + *) return 1 ;; + esac +} + +known_env_keys_csv() { + local IFS="," + printf '%s' "${KNOWN_ENV_KEYS[*]}" +} + +array_contains() { + local needle="$1" + shift + local item + for item in "$@"; do + [[ "${item}" == "${needle}" ]] && return 0 + done + return 1 +} + +write_json_file() { + local path="$1" + local payload="$2" + PAYLOAD_JSON="${payload}" python3 - "${path}" <<'PY' +import json +import os +import pathlib +import sys + +target = pathlib.Path(sys.argv[1]) +target.parent.mkdir(parents=True, exist_ok=True) +payload = json.loads(os.environ.get("PAYLOAD_JSON", "{}")) +target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY +} + +write_agent_manifest() { + local path="$1" + local title="$2" + local description="$3" + python3 - "${path}" "${title}" "${description}" <<'PY' +import json +import pathlib +import sys + +target = pathlib.Path(sys.argv[1]) +target.parent.mkdir(parents=True, exist_ok=True) +payload = { + "title": sys.argv[2], + "description": sys.argv[3], +} +target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY +} + +first_meaningful_line() { + local file="$1" + python3 - "${file}" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +if not path.is_file(): + sys.exit(0) +for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + stripped = re.sub(r"^#+\s*", "", line).strip() + if stripped: + print(stripped) + break +PY +} + +ensure_env_key() { + local source_key="$1" + local value="$2" + local source_label="$3" + local target_key="" + + if ! target_key="$(map_env_key "${source_key}" 2>/dev/null)"; then + return 0 + fi + + if [[ -z "${target_key}" || -z "${value}" ]]; then + return 0 + fi + + if grep -q "^${target_key}=" "${A0_ENV}" 2>/dev/null; then + log_skipped "${target_key} already set in ${A0_ENV}" + return 0 + fi + + printf '%s=%s\n' "${target_key}" "${value}" >> "${A0_ENV}" + log_migrated "${source_key} → ${target_key} (${source_label})" +} + +extract_known_env_keys_from_json() { + local json_file="$1" + python3 - "${json_file}" "$(known_env_keys_csv)" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +candidates = {item for item in sys.argv[2].split(",") if item} +if not path.is_file(): + sys.exit(0) + +data = json.loads(path.read_text(encoding="utf-8")) +seen = set() + +def walk(node): + if isinstance(node, dict): + for key, value in node.items(): + if key in candidates and isinstance(value, str) and value: + pair = (key, value) + if pair not in seen: + seen.add(pair) + print(f"{key}\t{value}") + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + +walk(data) +PY +} + +parse_config_strict() { + python3 - "${OPENCLAW_CONFIG}" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +json.dump(data, sys.stdout) +PY +} + +parse_config_python_json5() { + python3 - "${OPENCLAW_CONFIG}" <<'PY' +import json +import pathlib +import sys + +import json5 # type: ignore + +path = pathlib.Path(sys.argv[1]) +data = json5.loads(path.read_text(encoding="utf-8")) +json.dump(data, sys.stdout) +PY +} + +parse_config_node_json5() { + node - "${OPENCLAW_CONFIG}" <<'NODE' +const fs = require("fs"); +const JSON5 = require("json5"); + +const filePath = process.argv[2]; +const raw = fs.readFileSync(filePath, "utf8"); +const parsed = JSON5.parse(raw); +process.stdout.write(JSON.stringify(parsed)); +NODE +} + +config_env_refs() { + python3 - "${OPENCLAW_CONFIG}" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +if not path.is_file(): + sys.exit(0) + +raw = path.read_text(encoding="utf-8", errors="replace") +for name in sorted(set(re.findall(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", raw))): + print(name) +PY +} + +jval() { + local path="$1" + local default="${2:-}" + CONFIG_JSON_INPUT="${CONFIG_JSON}" python3 - "${path}" "${default}" <<'PY' +import json +import os +import sys + +path = sys.argv[1].strip(".") +default = sys.argv[2] +raw = os.environ.get("CONFIG_JSON_INPUT", "{}").strip() or "{}" +config = json.loads(raw) +value = config + +for key in [part for part in path.split(".") if part]: + if isinstance(value, dict): + value = value.get(key) + elif isinstance(value, list) and key.isdigit(): + index = int(key) + value = value[index] if index < len(value) else None + else: + value = None + if value is None: + break + +if value is None: + sys.stdout.write(default) +elif isinstance(value, (dict, list)): + json.dump(value, sys.stdout) +else: + sys.stdout.write(str(value)) +PY +} + +copy_dir_with_collision_handling() { + local source_dir="$1" + local target_dir="$2" + local label="$3" + + if [[ ! -d "${target_dir}" ]]; then + mkdir -p "$(dirname "${target_dir}")" + cp -R "${source_dir}" "${target_dir}" + log_migrated "${label}" + return 0 + fi + + if diff -qr "${source_dir}" "${target_dir}" >/dev/null 2>&1; then + log_skipped "${label} already exists with identical contents" + else + log_warning "${label} already exists with different contents: ${target_dir}" + fi +} + +build_telegram_payload() { + CONFIG_JSON_INPUT="${CONFIG_JSON}" python3 - "${OPENCLAW_ENV}" <<'PY' +import json +import os +import pathlib +import sys + +env_path = pathlib.Path(sys.argv[1]) +config = json.loads(os.environ.get("CONFIG_JSON_INPUT", "{}") or "{}") +telegram = (((config.get("channels") or {}).get("telegram")) or {}) + +env_values = {} +if env_path.is_file(): + for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env_values[key.strip()] = value.strip().strip('"').strip("'") + +def normalize_allowed_users(raw): + if not isinstance(raw, list): + return [] + result = [] + for item in raw: + value = str(item).replace("telegram:", "").replace("tg:", "").strip() + if value and value != "*" and value not in result: + result.append(value) + return result + +def normalize_group_mode(raw_groups): + if raw_groups is False: + return "off" + if not isinstance(raw_groups, dict) or not raw_groups: + return "mention" + for key in ["*", *raw_groups.keys()]: + value = raw_groups.get(key) + if not isinstance(value, dict): + continue + if value.get("enabled") is False: + return "off" + if value.get("requireMention") is False: + return "all" + if value.get("requireMention") is True: + return "mention" + return "mention" + +def webhook_fields(raw): + if not isinstance(raw, dict): + return ("polling", "", "") + enabled = raw.get("enabled") + if enabled is False: + return ("polling", "", "") + url = raw.get("url") or raw.get("webhookUrl") or raw.get("baseUrl") or "" + secret = raw.get("secret") or raw.get("webhookSecret") or "" + if url: + return ("webhook", str(url), str(secret)) + return ("polling", "", "") + +def build_bot(name, source): + if not isinstance(source, dict): + return None, [f"telegram account '{name}' is not an object; skipped"] + token = source.get("botToken") or telegram.get("botToken") or env_values.get("TELEGRAM_BOT_TOKEN", "") + warnings = [] + if not token or "${" in str(token): + warnings.append(f"telegram account '{name}' has no concrete bot token; skipped") + return None, warnings + mode, webhook_url, webhook_secret = webhook_fields(source.get("webhook") or telegram.get("webhook")) + bot = { + "name": name, + "enabled": True, + "token": str(token), + "mode": mode, + "webhook_url": webhook_url, + "webhook_secret": webhook_secret, + "allowed_users": normalize_allowed_users(source.get("allowFrom", telegram.get("allowFrom", []))), + "group_mode": normalize_group_mode(source.get("groups", telegram.get("groups", {}))), + } + return bot, warnings + +bots = [] +warnings = [] +notes = [] + +accounts = telegram.get("accounts") +if isinstance(accounts, dict) and accounts: + for account_name, account_cfg in accounts.items(): + bot, bot_warnings = build_bot(str(account_name), account_cfg) + warnings.extend(bot_warnings) + if bot: + bots.append(bot) +else: + bot, bot_warnings = build_bot("default", telegram if isinstance(telegram, dict) else {}) + warnings.extend(bot_warnings) + if bot: + bots.append(bot) + +dm_policy = telegram.get("dmPolicy") +if dm_policy: + notes.append(f"OpenClaw dmPolicy was '{dm_policy}' and was not mapped directly.") + +if telegram.get("bindings"): + notes.append("OpenClaw Telegram bindings were not migrated; review channel routing manually.") + +if telegram.get("pairing"): + notes.append("OpenClaw Telegram pairing behavior was not migrated.") + +payload = { + "bots": bots, + "warnings": warnings, + "notes": notes, +} +json.dump(payload, sys.stdout) +PY +} + +header "OpenClaw → Agent Zero Migration" +echo +info "OpenClaw dir : ${OPENCLAW_DIR}" +info "Agent Zero : ${A0_USR_DIR}" +echo + +if [[ ! -d "${OPENCLAW_DIR}" ]]; then + error "OpenClaw directory not found: ${OPENCLAW_DIR}" + error "Usage: $0 [--include-auth-profiles] [OPENCLAW_DIR] [A0_USR_DIR]" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + error "Python 3 is required but not found." + exit 1 +fi + +mkdir -p "${A0_USR_DIR}" +touch "${A0_ENV}" + +cat > "${MIGRATION_REPORT}" </dev/null 2>&1; then + CONFIG_JSON="$(parse_config_python_json5)" + CONFIG_PARSE_MODE="python-json5" + success "Parsed openclaw.json with Python json5 parser" + report_line "- Config parser: ${CONFIG_PARSE_MODE}" + elif command -v node >/dev/null 2>&1 && node -e "require('json5')" >/dev/null 2>&1; then + CONFIG_JSON="$(parse_config_node_json5)" + CONFIG_PARSE_MODE="node-json5" + success "Parsed openclaw.json with Node json5 parser" + report_line "- Config parser: ${CONFIG_PARSE_MODE}" + else + error "Could not parse ${OPENCLAW_CONFIG}." + error "The file appears to require JSON5 support, but no JSON5-capable parser is available." + error "Install python package 'json5' or a Node runtime with the 'json5' package, then rerun." + exit 1 + fi +else + warn "No openclaw.json found at ${OPENCLAW_CONFIG}" + warn "Will still attempt to migrate workspace files and .env" + report_line "- Config parser: config file missing" +fi + +header "Step 1: API Keys" + +if [[ -f "${OPENCLAW_ENV}" ]]; then + info "Reading ${OPENCLAW_ENV}" + while IFS='=' read -r key value || [[ -n "${key}" ]]; do + [[ -z "${key}" || "${key}" =~ ^# ]] && continue + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + ensure_env_key "${key}" "${value}" ".env" + done < "${OPENCLAW_ENV}" +else + log_skipped "No .env file found at ${OPENCLAW_ENV}" +fi + +if [[ -f "${OPENCLAW_CONFIG}" ]]; then + while IFS= read -r ref_name; do + [[ -z "${ref_name}" ]] && continue + target_key="$(map_env_key "${ref_name}" 2>/dev/null || true)" + if [[ -n "${target_key}" ]]; then + if [[ -n "${!ref_name-}" ]]; then + ensure_env_key "${ref_name}" "${!ref_name}" "shell environment (referenced by openclaw.json)" + elif grep -q "^${ref_name}=" "${OPENCLAW_ENV}" 2>/dev/null; then + : + else + log_warning "Config references ${ref_name}; set ${target_key} manually if needed" + fi + fi + done < <(config_env_refs) +fi + +if [[ "${INCLUDE_AUTH_PROFILES}" == "1" ]]; then + if [[ -f "${OPENCLAW_AUTH_PROFILES}" ]]; then + while IFS=$'\t' read -r key value; do + [[ -z "${key}" || -z "${value}" ]] && continue + ensure_env_key "${key}" "${value}" "auth-profiles.json" + done < <(extract_known_env_keys_from_json "${OPENCLAW_AUTH_PROFILES}") + else + log_skipped "No auth-profiles.json found at ${OPENCLAW_AUTH_PROFILES}" + fi +else + log_skipped "auth-profiles.json scan disabled (use --include-auth-profiles to enable)" +fi + +header "Step 2: Discover Agents" + +AGENTS_JSON="$(jval '.agents.list' '[]')" +DEFAULT_WORKSPACE="$(jval '.agents.defaults.workspace' "${OPENCLAW_DIR}/workspace")" +DEFAULT_WORKSPACE="${DEFAULT_WORKSPACE/#\~/$HOME}" + +declare -a AGENT_IDS=() +declare -a AGENT_NAMES=() +declare -a AGENT_WORKSPACES=() +declare -a AGENT_PROFILE_IDS=() + +if [[ "${AGENTS_JSON}" != "[]" && "${AGENTS_JSON}" != "" ]]; then + while IFS=$'\t' read -r raw_id raw_name raw_ws; do + [[ -z "${raw_id}" ]] && continue + AGENT_IDS+=("${raw_id}") + AGENT_NAMES+=("${raw_name}") + AGENT_WORKSPACES+=("${raw_ws}") + done < <(AGENTS_JSON_INPUT="${AGENTS_JSON}" python3 - <<'PY' +import json +import os +import sys + +agents = json.loads(os.environ.get("AGENTS_JSON_INPUT", "[]") or "[]") +if not isinstance(agents, list): + agents = [] + +for index, agent in enumerate(agents): + if not isinstance(agent, dict): + continue + aid = str(agent.get("id") or f"agent{index}") + name = str(agent.get("name") or aid) + workspace = str(agent.get("workspace") or "") + aid = aid.replace("\t", " ") + name = name.replace("\t", " ") + workspace = workspace.replace("\t", " ") + print(f"{aid}\t{name}\t{workspace}") +PY +) +fi + +if [[ ${#AGENT_IDS[@]} -eq 0 ]]; then + AGENT_IDS=("main") + AGENT_NAMES=("Main") + AGENT_WORKSPACES=("") +fi + +declare -a USED_PROFILE_IDS=() + +for i in "${!AGENT_IDS[@]}"; do + aid="${AGENT_IDS[$i]}" + ws="${AGENT_WORKSPACES[$i]}" + if [[ -z "${ws}" ]]; then + if [[ -d "${OPENCLAW_DIR}/workspace-${aid}" ]]; then + ws="${OPENCLAW_DIR}/workspace-${aid}" + elif [[ -d "${DEFAULT_WORKSPACE}" ]]; then + ws="${DEFAULT_WORKSPACE}" + else + ws="${OPENCLAW_DIR}/workspace" + fi + else + ws="${ws/#\~/$HOME}" + fi + AGENT_WORKSPACES[$i]="${ws}" + + base_profile_id="$(sanitize_agent_id "${aid}")" + profile_id="${base_profile_id}" + suffix=2 + while array_contains "${profile_id}" "${USED_PROFILE_IDS[@]-}"; do + profile_id="${base_profile_id}-${suffix}" + suffix=$((suffix + 1)) + done + USED_PROFILE_IDS+=("${profile_id}") + AGENT_PROFILE_IDS[$i]="${profile_id}" +done + +info "Found ${#AGENT_IDS[@]} agent(s):" +for i in "${!AGENT_IDS[@]}"; do + aid="${AGENT_IDS[$i]}" + ws="${AGENT_WORKSPACES[$i]}" + profile_id="${AGENT_PROFILE_IDS[$i]}" + name="${AGENT_NAMES[$i]}" + if [[ -d "${ws}" ]]; then + echo " • ${aid} (${name}) → ${ws} [profile: ${profile_id}] ✓" + else + echo " • ${aid} (${name}) → ${ws} [profile: ${profile_id}] ✗ (not found)" + fi +done + +header "Step 3: Agent Profiles and Prompt Content" + +for i in "${!AGENT_IDS[@]}"; do + aid="${AGENT_IDS[$i]}" + ws="${AGENT_WORKSPACES[$i]}" + name="${AGENT_NAMES[$i]}" + profile_id="${AGENT_PROFILE_IDS[$i]}" + + if [[ ! -d "${ws}" ]]; then + log_warning "Workspace not found for agent '${aid}': ${ws}" + continue + fi + + AGENT_DIR="${A0_USR_DIR}/agents/${profile_id}" + PROMPTS_DIR="${AGENT_DIR}/prompts" + AGENT_SKILLS_DIR="${AGENT_DIR}/skills" + AGENT_WORKDIR="${MIGRATION_WORKDIR_ROOT}/${profile_id}" + mkdir -p "${PROMPTS_DIR}" "${AGENT_SKILLS_DIR}" "${AGENT_WORKDIR}" + + info "Migrating OpenClaw agent '${aid}' into Agent Zero profile '${profile_id}'" + + desc="Generated from OpenClaw agent '${aid}'" + if [[ -f "${ws}/IDENTITY.md" ]]; then + first_line="$(first_meaningful_line "${ws}/IDENTITY.md" || true)" + [[ -n "${first_line}" ]] && desc="${first_line}" + fi + + if [[ ! -f "${AGENT_DIR}/agent.json" ]]; then + write_agent_manifest "${AGENT_DIR}/agent.json" "${name}" "${desc}" + log_migrated "agent.json for profile '${profile_id}'" + else + log_skipped "agent.json already exists for profile '${profile_id}'" + fi + + role_target="${PROMPTS_DIR}/agent.system.main.role.md" + if [[ -f "${ws}/SOUL.md" ]]; then + if [[ ! -f "${role_target}" ]]; then + { + echo "# Agent Role" + echo + echo "> Migrated from OpenClaw SOUL.md." + echo + cat "${ws}/SOUL.md" + } > "${role_target}" + log_migrated "SOUL.md → agent.system.main.role.md (profile '${profile_id}')" + else + log_skipped "agent.system.main.role.md already exists for profile '${profile_id}'" + fi + fi + + promptinclude_created=0 + + if [[ -f "${ws}/IDENTITY.md" ]]; then + target="${AGENT_WORKDIR}/identity.promptinclude.md" + if [[ ! -f "${target}" ]]; then + { + echo "# Agent Identity" + echo + echo "> Migrated from OpenClaw IDENTITY.md." + echo + cat "${ws}/IDENTITY.md" + } > "${target}" + log_migrated "IDENTITY.md → ${target}" + promptinclude_created=1 + else + log_skipped "identity.promptinclude.md already exists for profile '${profile_id}'" + fi + fi + + if [[ -f "${ws}/USER.md" ]]; then + target="${AGENT_WORKDIR}/user.promptinclude.md" + if [[ ! -f "${target}" ]]; then + { + echo "# User Profile" + echo + echo "> Migrated from OpenClaw USER.md." + echo + cat "${ws}/USER.md" + } > "${target}" + log_migrated "USER.md → ${target}" + promptinclude_created=1 + else + log_skipped "user.promptinclude.md already exists for profile '${profile_id}'" + fi + fi + + if [[ -f "${ws}/MEMORY.md" ]]; then + target="${AGENT_WORKDIR}/memory.promptinclude.md" + if [[ ! -f "${target}" ]]; then + { + echo "# Long-Term Memory" + echo + echo "> Migrated from OpenClaw MEMORY.md." + echo "> Agent Zero will load this only when the active workdir or project points to this folder." + echo + cat "${ws}/MEMORY.md" + } > "${target}" + log_migrated "MEMORY.md → ${target}" + promptinclude_created=1 + else + log_skipped "memory.promptinclude.md already exists for profile '${profile_id}'" + fi + + target="${MEMORY_KNOWLEDGE_DIR}/${profile_id}/MEMORY.md" + if [[ ! -f "${target}" ]]; then + mkdir -p "$(dirname "${target}")" + cp "${ws}/MEMORY.md" "${target}" + log_migrated "MEMORY.md → knowledge/custom/openclaw-memory/${profile_id}/MEMORY.md" + else + log_skipped "Memory knowledge file already exists for profile '${profile_id}'" + fi + fi + + readme_target="${AGENT_WORKDIR}/README.md" + if [[ ! -f "${readme_target}" ]]; then + { + echo "# OpenClaw Promptinclude Migration" + echo + echo "This folder contains promptinclude files generated from OpenClaw workspace content for Agent Zero profile \`${profile_id}\`." + echo + echo "To have Agent Zero load these files with the \`_promptinclude\` plugin, set the active workdir or project to this folder." + echo + echo "- OpenClaw agent id: \`${aid}\`" + echo "- Source workspace: \`${ws}\`" + } > "${readme_target}" + log_migrated "Promptinclude README for profile '${profile_id}'" + else + log_skipped "Promptinclude README already exists for profile '${profile_id}'" + fi + + specifics_target="${PROMPTS_DIR}/agent.system.main.specifics.md" + if [[ ! -f "${specifics_target}" ]]; then + if [[ -f "${ws}/AGENTS.md" || -f "${ws}/TOOLS.md" || -f "${ws}/IDENTITY.md" || -f "${ws}/USER.md" || -f "${ws}/MEMORY.md" ]]; then + { + echo "# Agent Specifics" + echo + echo "> Generated from OpenClaw workspace \`${ws}\`." + echo "> This Agent Zero profile preserves prompt content, but not OpenClaw auth/session/channel isolation." + echo + if [[ -f "${ws}/AGENTS.md" ]]; then + echo "## Operating Instructions" + echo + cat "${ws}/AGENTS.md" + echo + fi + if [[ -f "${ws}/TOOLS.md" ]]; then + echo "## Local Tool Notes" + echo + cat "${ws}/TOOLS.md" + echo + fi + echo "## Migration Notes" + echo + echo "- Promptinclude files were written to \`${AGENT_WORKDIR}\`." + echo "- To load \`IDENTITY.md\`, \`USER.md\`, and \`MEMORY.md\` automatically, point Agent Zero workdir or project to that folder." + echo "- \`memory/*.md\` files were copied into \`${KNOWLEDGE_DIR}/${profile_id}\` for searchability." + if [[ -f "${ws}/MEMORY.md" ]]; then + echo "- \`MEMORY.md\` was also copied into \`${MEMORY_KNOWLEDGE_DIR}/${profile_id}/MEMORY.md\` for reference." + fi + } > "${specifics_target}" + log_migrated "agent.system.main.specifics.md for profile '${profile_id}'" + fi + else + log_skipped "agent.system.main.specifics.md already exists for profile '${profile_id}'" + fi + + if [[ -d "${ws}/memory" ]]; then + copied_count=0 + mkdir -p "${KNOWLEDGE_DIR}/${profile_id}" + for md_file in "${ws}/memory/"*.md; do + [[ ! -f "${md_file}" ]] && continue + target="${KNOWLEDGE_DIR}/${profile_id}/$(basename "${md_file}")" + if [[ ! -f "${target}" ]]; then + cp "${md_file}" "${target}" + copied_count=$((copied_count + 1)) + fi + done + if [[ ${copied_count} -gt 0 ]]; then + log_migrated "${copied_count} memory/*.md files → knowledge/custom/openclaw/${profile_id}" + else + log_skipped "No new memory/*.md files for profile '${profile_id}'" + fi + fi +done + +header "Step 4: Telegram" + +TG_PLUGIN_DIR="${A0_USR_DIR}/plugins/_telegram_integration" +TG_CONFIG="${TG_PLUGIN_DIR}/config.json" +TG_NOTES="${TG_PLUGIN_DIR}/openclaw-migration-notes.md" +mkdir -p "${TG_PLUGIN_DIR}" + +telegram_payload="$(build_telegram_payload)" +telegram_bot_count="$(TELEGRAM_PAYLOAD="${telegram_payload}" python3 - <<'PY' +import json +import os +import sys +payload = json.loads(os.environ.get("TELEGRAM_PAYLOAD", "{}") or "{}") +print(len(payload.get("bots") or [])) +PY +)" + +if [[ "${telegram_bot_count}" -gt 0 ]]; then + if [[ ! -f "${TG_CONFIG}" ]]; then + TELEGRAM_PAYLOAD="${telegram_payload}" python3 - "${TG_CONFIG}" <<'PY' +import json +import os +import pathlib +import sys + +payload = json.loads(os.environ.get("TELEGRAM_PAYLOAD", "{}") or "{}") +target = pathlib.Path(sys.argv[1]) +target.parent.mkdir(parents=True, exist_ok=True) +config = {"bots": payload.get("bots", [])} +target.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") +PY + log_migrated "Telegram config → ${TG_CONFIG}" + else + log_skipped "Telegram config already exists at ${TG_CONFIG}" + log_warning "Review existing Telegram config manually: ${TG_CONFIG}" + fi + + { + echo "# OpenClaw Telegram Migration Notes" + echo + echo "- Generated from: \`${OPENCLAW_CONFIG}\`" + echo "- Output config: \`${TG_CONFIG}\`" + echo "- Bot count migrated: ${telegram_bot_count}" + echo + echo "## Manual Review Items" + echo + echo "- Per-group policies are mapped conservatively to Agent Zero \`group_mode\`." + echo "- OpenClaw bindings, pairing, and DM policy do not map cleanly and require manual review." + echo "- Session history and channel routing are not migrated." + echo + echo "## Payload Notes" + echo + TELEGRAM_PAYLOAD="${telegram_payload}" python3 - <<'PY' +import json +import os +import sys + +payload = json.loads(os.environ.get("TELEGRAM_PAYLOAD", "{}") or "{}") +notes = payload.get("notes") or [] +warnings = payload.get("warnings") or [] + +if not notes and not warnings: + print("- No extra migration notes emitted.") +else: + for item in notes: + print(f"- {item}") + for item in warnings: + print(f"- WARNING: {item}") +PY + } > "${TG_NOTES}" + log_migrated "Telegram migration notes → ${TG_NOTES}" + + while IFS= read -r tg_warning; do + [[ -z "${tg_warning}" ]] && continue + log_warning "${tg_warning}" + done < <(TELEGRAM_PAYLOAD="${telegram_payload}" python3 - <<'PY' +import json +import os +import sys + +payload = json.loads(os.environ.get("TELEGRAM_PAYLOAD", "{}") or "{}") +for item in payload.get("warnings") or []: + print(item) +PY +) +else + log_skipped "No Telegram bot tokens found to migrate" +fi + +header "Step 5: Skills" + +for i in "${!AGENT_IDS[@]}"; do + aid="${AGENT_IDS[$i]}" + ws="${AGENT_WORKSPACES[$i]}" + profile_id="${AGENT_PROFILE_IDS[$i]}" + [[ ! -d "${ws}/skills" ]] && continue + + for skill_dir in "${ws}/skills/"*/; do + [[ ! -d "${skill_dir}" ]] && continue + skill_name="$(basename "${skill_dir}")" + target="${A0_USR_DIR}/agents/${profile_id}/skills/${skill_name}" + copy_dir_with_collision_handling "${skill_dir%/}" "${target}" "Workspace skill '${skill_name}' for profile '${profile_id}'" + done +done + +if [[ -d "${OPENCLAW_DIR}/skills" ]]; then + for skill_dir in "${OPENCLAW_DIR}/skills/"*/; do + [[ ! -d "${skill_dir}" ]] && continue + skill_name="$(basename "${skill_dir}")" + target="${A0_USR_DIR}/skills/${skill_name}" + copy_dir_with_collision_handling "${skill_dir%/}" "${target}" "Global skill '${skill_name}'" + done +fi + +header "Migration Complete" +echo +echo -e " ${GREEN}Migrated${NC} : ${MIGRATED} items" +echo -e " ${BLUE}Skipped${NC} : ${SKIPPED} items" +echo -e " ${YELLOW}Warnings${NC} : ${WARNINGS}" +echo + +{ + echo "# OpenClaw → Agent Zero Migration Log" + echo "Date: $(date -Iseconds)" + echo "Source: ${OPENCLAW_DIR}" + echo "Target: ${A0_USR_DIR}" + echo "Config parser: ${CONFIG_PARSE_MODE}" + echo "Migrated: ${MIGRATED}" + echo "Skipped: ${SKIPPED}" + echo "Warnings: ${WARNINGS}" +} > "${MIGRATION_LOG}" + +report_line +report_line "## Manual Follow-Up" +report_line +report_line "- Set Agent Zero workdir or project to one of the generated folders under \`${MIGRATION_WORKDIR_ROOT}\` if you want the migrated \`*.promptinclude.md\` files loaded automatically." +report_line "- Review Telegram routing, DM policy, bindings, and webhook details before enabling the plugin." +report_line "- OpenClaw OAuth profiles, session history, channel state, and agent isolation are not fully portable." +report_line "- Review migrated profiles under Settings → Agents and verify prompts, skills, and model settings." + +info "Migration log: ${MIGRATION_LOG}" +info "Migration report: ${MIGRATION_REPORT}" +echo + +if [[ ${MIGRATED} -gt 0 ]]; then + echo -e "${BOLD}Next steps:${NC}" + echo " 1. Review generated profiles under Settings → Agents" + echo " 2. Point Agent Zero workdir/project to ${MIGRATION_WORKDIR_ROOT}/ if you want promptinclude behavior" + echo " 3. Review knowledge files under ${KNOWLEDGE_DIR} and ${MEMORY_KNOWLEDGE_DIR}" + echo " 4. Review ${TG_CONFIG} and ${TG_NOTES} before enabling Telegram" + echo +fi + +if [[ ${WARNINGS} -gt 0 ]]; then + echo -e "${BOLD}${YELLOW}Review warnings above for items needing manual attention.${NC}" + echo +fi + +echo -e "${BOLD}Not migrated automatically:${NC}" +echo " • OpenClaw auth/session/channel isolation semantics" +echo " • OAuth profiles beyond API-key-style secrets" +echo " • Session history / transcripts" +echo " • Channel bindings / agent routing" +echo " • Heartbeat / cron schedules (use A0 Task Scheduler)" +echo " • Model selection and per-project UI settings" +echo From 43e38e2e4805a27a5ca37bdb41f239ecc0dc800f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Le=C3=A3o?= Date: Sun, 5 Apr 2026 08:49:28 -0300 Subject: [PATCH 2/5] add telegram setup during install --- install.sh | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 6f47f79..d692966 100644 --- a/install.sh +++ b/install.sh @@ -97,6 +97,124 @@ wait_for_keypress() { IFS= read -rsn1 _key /dev/null 2>&1; then + print_error "Python 3 is required to write Telegram configuration." + exit 1 + fi + + _result="$( + TELEGRAM_CONFIG_FILE="$_config_file" \ + TELEGRAM_BOT_TOKEN="$_token" \ + TELEGRAM_ALLOWED_USER="$_allowed_user" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +config_file = Path(os.environ["TELEGRAM_CONFIG_FILE"]) +token = os.environ["TELEGRAM_BOT_TOKEN"].strip() +allowed_user = os.environ["TELEGRAM_ALLOWED_USER"].strip() + +config = {} +if config_file.exists(): + try: + loaded = json.loads(config_file.read_text()) + if isinstance(loaded, dict): + config = loaded + except Exception: + config = {} + +bots = config.get("bots") +if not isinstance(bots, list): + bots = [] + +existing = None +for bot in bots: + if isinstance(bot, dict) and bot.get("token") == token: + existing = bot + break + +if existing is None: + used_names = { + str(bot.get("name", "")).strip() + for bot in bots + if isinstance(bot, dict) and str(bot.get("name", "")).strip() + } + idx = 1 + name = f"bot_{idx}" + while name in used_names: + idx += 1 + name = f"bot_{idx}" + existing = {"name": name} + bots.append(existing) + action = "created" +else: + action = "updated" + +existing.update({ + "enabled": True, + "notify_messages": bool(existing.get("notify_messages", False)), + "token": token, + "mode": "polling", + "webhook_url": "", + "webhook_secret": "", + "allowed_users": [allowed_user] if allowed_user else [], + "group_mode": "mention", + "welcome_enabled": bool(existing.get("welcome_enabled", False)), + "welcome_message": existing.get("welcome_message", ""), + "user_projects": existing.get("user_projects", {}) if isinstance(existing.get("user_projects"), dict) else {}, + "default_project": existing.get("default_project", ""), + "attachment_max_age_hours": int(existing.get("attachment_max_age_hours", 0) or 0), + "agent_instructions": existing.get("agent_instructions", ""), +}) + +config["bots"] = bots +config_file.write_text(json.dumps(config, indent=2) + "\n") +print(f"{action}:{existing['name']}") +PY + )" + + case "$_result" in + created:*) + print_ok "Telegram bot configured: ${_result#created:}" + ;; + updated:*) + print_ok "Telegram bot updated: ${_result#updated:}" + ;; + *) + print_warn "Telegram config written to $_config_file" + ;; + esac +} + # Check whether a TCP port is in use on localhost. # Uses a fallback chain: lsof → nc → /dev/tcp (for broad OS compatibility). # Also checks Docker container port mappings directly. @@ -747,13 +865,16 @@ create_instance() { PORT="" AUTH_LOGIN="" AUTH_PASSWORD="" + TELEGRAM_SETUP=0 + TELEGRAM_BOT_TOKEN="" + TELEGRAM_ALLOWED_USER="" # Compute defaults once up front DEFAULT_PORT="$(find_free_port 5080)" DEFAULT_NAME="$(suggest_next_instance_name "agent-zero")" QUICK_START=0 WIZARD_STEP=0 - while [ "$WIZARD_STEP" -ge 0 ] && [ "$WIZARD_STEP" -le 6 ]; do + while [ "$WIZARD_STEP" -ge 0 ] && [ "$WIZARD_STEP" -le 9 ]; do case "$WIZARD_STEP" in 0) # Quick Start vs Manual mode selection _rc=0 @@ -874,7 +995,63 @@ create_instance() { read_input "12345678" || { WIZARD_STEP=5; continue; } AUTH_PASSWORD="${INPUT_VALUE:-12345678}" print_info "Auth configured for user: $AUTH_LOGIN" - WIZARD_STEP=7 # Done gathering input + WIZARD_STEP=7 + ;; + + 7) # Telegram setup + _rc=0 + prompt_yes_no "Set up Telegram now?" || _rc=$? + case "$_rc" in + 0) + TELEGRAM_SETUP=1 + WIZARD_STEP=8 + ;; + 1) + if [ -n "$AUTH_LOGIN" ]; then + WIZARD_STEP=6 + else + WIZARD_STEP=5 + fi + continue + ;; + 2) + TELEGRAM_SETUP=0 + TELEGRAM_BOT_TOKEN="" + TELEGRAM_ALLOWED_USER="" + WIZARD_STEP=10 + ;; + esac + ;; + + 8) # Telegram bot token + clear + print_banner + echo "" + printf "${BOLD}Telegram bot token${NC} (Esc to go back)\n" + read_input "$TELEGRAM_BOT_TOKEN" || { WIZARD_STEP=7; continue; } + TELEGRAM_BOT_TOKEN="${INPUT_VALUE}" + if [ -z "$TELEGRAM_BOT_TOKEN" ]; then + print_warn "Telegram bot token cannot be empty." + sleep 1 + continue + fi + WIZARD_STEP=9 + ;; + + 9) # Telegram allow list + clear + print_banner + echo "" + printf "${BOLD}Allowed Telegram user ID or @username${NC} (Esc to go back)\n" + printf "Leave empty to allow anyone to message the bot:\n" + read_input "$TELEGRAM_ALLOWED_USER" || { WIZARD_STEP=8; continue; } + TELEGRAM_ALLOWED_USER="${INPUT_VALUE}" + if [ -z "$TELEGRAM_ALLOWED_USER" ]; then + print_warn "Telegram bot will allow messages from any user." + else + print_info "Telegram allow list set to: $TELEGRAM_ALLOWED_USER" + fi + WIZARD_STEP=10 ;; esac done @@ -888,6 +1065,10 @@ create_instance() { # ----------------------------------------------------------- mkdir -p "$INSTANCE_DIR" + if [ "$TELEGRAM_SETUP" = "1" ]; then + write_telegram_config "$DATA_DIR" "$TELEGRAM_BOT_TOKEN" "$TELEGRAM_ALLOWED_USER" + fi + local IMAGE="agent0ai/agent-zero:$SELECTED_TAG" print_info "Pulling Agent Zero image (this may take a moment)..." @@ -1197,4 +1378,4 @@ main() { fi } -main "$@" \ No newline at end of file +main "$@" From d8af4704a327bc649a9cc822a44cf7bd01c24e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Le=C3=A3o?= Date: Sun, 5 Apr 2026 09:07:49 -0300 Subject: [PATCH 3/5] add telegram setup step on windows installer --- install.ps1 | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 72c299f..e41552e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -230,6 +230,219 @@ function select_from_menu { } } +function prompt_yes_no { + param( + [string]$Header + ) + + $selectedIndex = select_from_menu -Header $Header -Options @('Yes', 'No') + if ($selectedIndex -eq -1) { + return 1 + } + + if ($selectedIndex -eq 0) { + return 0 + } + + return 2 +} + +function Get-ConfigPropertyValue { + param( + [object]$Object, + [string]$Name, + [object]$Default = $null + ) + + if ($null -eq $Object) { + return $Default + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { + return $Object[$Name] + } + return $Default + } + + $prop = $Object.PSObject.Properties[$Name] + if ($null -ne $prop) { + return $prop.Value + } + + return $Default +} + +function Get-BoolConfigValue { + param( + [object]$Value, + [bool]$Default = $false + ) + + if ($null -eq $Value) { + return $Default + } + + if ($Value -is [bool]) { + return $Value + } + + $text = ([string]$Value).Trim().ToLowerInvariant() + switch ($text) { + 'true' { return $true } + '1' { return $true } + 'false' { return $false } + '0' { return $false } + default { return $Default } + } +} + +function Get-IntConfigValue { + param( + [object]$Value, + [int]$Default = 0 + ) + + if ($null -eq $Value) { + return $Default + } + + try { + return [int]$Value + } + catch { + return $Default + } +} + +function ConvertTo-OrderedMap { + param( + [object]$InputObject + ) + + $map = [ordered]@{} + + if ($null -eq $InputObject) { + return $map + } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + $map[$key] = $InputObject[$key] + } + return $map + } + + foreach ($prop in $InputObject.PSObject.Properties) { + $map[$prop.Name] = $prop.Value + } + + return $map +} + +function write_telegram_config { + param( + [string]$DataDir, + [string]$Token, + [string]$AllowedUser + ) + + $pluginDir = Join-Path $DataDir 'plugins\_telegram_integration' + $configFile = Join-Path $pluginDir 'config.json' + New-Item -ItemType Directory -Force -Path $pluginDir *> $null + + $config = [ordered]@{} + if (Test-Path $configFile) { + try { + $loaded = Get-Content -Raw -Path $configFile | ConvertFrom-Json + $config = ConvertTo-OrderedMap -InputObject $loaded + } + catch { + $config = [ordered]@{} + } + } + + $bots = @() + if ($config.Contains('bots') -and $null -ne $config['bots']) { + $bots = @($config['bots']) + } + + $existingIndex = -1 + $existingBot = $null + for ($i = 0; $i -lt $bots.Count; $i++) { + $candidateToken = [string](Get-ConfigPropertyValue -Object $bots[$i] -Name 'token' -Default '') + if ($candidateToken -eq $Token) { + $existingIndex = $i + $existingBot = $bots[$i] + break + } + } + + if ($existingIndex -lt 0) { + $usedNames = @{} + foreach ($bot in $bots) { + $botName = [string](Get-ConfigPropertyValue -Object $bot -Name 'name' -Default '') + if (-not [string]::IsNullOrWhiteSpace($botName)) { + $usedNames[$botName] = $true + } + } + + $nextIndex = 1 + $botName = "bot_$nextIndex" + while ($usedNames.ContainsKey($botName)) { + $nextIndex++ + $botName = "bot_$nextIndex" + } + + $existingBot = [ordered]@{ + name = $botName + } + $action = 'configured' + } + else { + $action = 'updated' + } + + $botConfig = ConvertTo-OrderedMap -InputObject $existingBot + $botConfig['enabled'] = $true + $botConfig['notify_messages'] = Get-BoolConfigValue -Value (Get-ConfigPropertyValue -Object $existingBot -Name 'notify_messages' -Default $false) + $botConfig['token'] = $Token + $botConfig['mode'] = 'polling' + $botConfig['webhook_url'] = '' + $botConfig['webhook_secret'] = '' + $botConfig['allowed_users'] = @() + if (-not [string]::IsNullOrWhiteSpace($AllowedUser)) { + $botConfig['allowed_users'] = @($AllowedUser) + } + $botConfig['group_mode'] = 'mention' + $botConfig['welcome_enabled'] = Get-BoolConfigValue -Value (Get-ConfigPropertyValue -Object $existingBot -Name 'welcome_enabled' -Default $false) + $botConfig['welcome_message'] = [string](Get-ConfigPropertyValue -Object $existingBot -Name 'welcome_message' -Default '') + $userProjects = Get-ConfigPropertyValue -Object $existingBot -Name 'user_projects' -Default $null + if ($userProjects -is [System.Collections.IDictionary] -or $userProjects -is [pscustomobject]) { + $botConfig['user_projects'] = ConvertTo-OrderedMap -InputObject $userProjects + } + else { + $botConfig['user_projects'] = [ordered]@{} + } + $botConfig['default_project'] = [string](Get-ConfigPropertyValue -Object $existingBot -Name 'default_project' -Default '') + $botConfig['attachment_max_age_hours'] = Get-IntConfigValue -Value (Get-ConfigPropertyValue -Object $existingBot -Name 'attachment_max_age_hours' -Default 0) + $botConfig['agent_instructions'] = [string](Get-ConfigPropertyValue -Object $existingBot -Name 'agent_instructions' -Default '') + + if ($existingIndex -lt 0) { + $bots += $botConfig + } + else { + $bots[$existingIndex] = $botConfig + } + + $config['bots'] = @($bots) + $json = $config | ConvertTo-Json -Depth 20 + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($configFile, $json + [Environment]::NewLine, $utf8NoBom) + + print_ok "Telegram bot $action: $($botConfig['name'])" +} + function check_docker_daemon_running { try { & docker info *> $null 2>&1 @@ -607,6 +820,9 @@ function create_instance { $port = '' $authLogin = '' $authPassword = '' + $telegramSetup = $false + $telegramBotToken = '' + $telegramAllowedUser = '' # Compute defaults once up front $defaultPort = Find-FreePort -BasePort 5080 @@ -614,7 +830,7 @@ function create_instance { $quickStart = $false $wizardStep = 0 - while ($wizardStep -ge 0 -and $wizardStep -le 6) { + while ($wizardStep -ge 0 -and $wizardStep -le 9) { switch ($wizardStep) { 0 { # Quick Start vs Manual mode selection @@ -743,7 +959,7 @@ function create_instance { } else { print_warn 'No authentication will be configured.' - $wizardStep = 7 # Done gathering input + $wizardStep = 7 } } @@ -761,7 +977,68 @@ function create_instance { $authPassword = '12345678' } print_info "Auth configured for user: $authLogin" - $wizardStep = 7 # Done gathering input + $wizardStep = 7 + } + + 7 { + $telegramChoice = prompt_yes_no -Header 'Set up Telegram now?' + switch ($telegramChoice) { + 0 { + $telegramSetup = $true + $wizardStep = 8 + } + 1 { + if (-not [string]::IsNullOrWhiteSpace($authLogin)) { + $wizardStep = 6 + } + else { + $wizardStep = 5 + } + continue + } + 2 { + $telegramSetup = $false + $telegramBotToken = '' + $telegramAllowedUser = '' + $wizardStep = 10 + } + } + } + + 8 { + Clear-Host + Show-Banner + Write-Host '' + Write-Host 'Telegram bot token' -ForegroundColor White -NoNewline + Write-Host ' (Esc to go back)' + Write-Host '> ' -NoNewline + $telegramBotToken = Read-InputWithEscape -Default $telegramBotToken + if ($null -eq $telegramBotToken) { $wizardStep = 7; continue } + if ([string]::IsNullOrWhiteSpace($telegramBotToken)) { + print_warn 'Telegram bot token cannot be empty.' + Start-Sleep -Seconds 1 + continue + } + $wizardStep = 9 + } + + 9 { + Clear-Host + Show-Banner + Write-Host '' + Write-Host 'Allowed Telegram user ID or @username' -ForegroundColor White -NoNewline + Write-Host ' (Esc to go back)' + Write-Host 'Leave empty to allow anyone to message the bot:' + Write-Host '> ' -NoNewline + $telegramAllowedUser = Read-InputWithEscape -Default $telegramAllowedUser + if ($null -eq $telegramAllowedUser) { $wizardStep = 8; continue } + if ([string]::IsNullOrWhiteSpace($telegramAllowedUser)) { + print_warn 'Telegram bot will allow messages from any user.' + } + else { + print_info "Telegram allow list set to: $telegramAllowedUser" + } + $wizardStep = 10 } } } @@ -773,6 +1050,10 @@ function create_instance { $instanceDir = Join-Path $installRoot $containerName New-Item -ItemType Directory -Force -Path $instanceDir *> $null + if ($telegramSetup) { + write_telegram_config -DataDir $dataDir -Token $telegramBotToken -AllowedUser $telegramAllowedUser + } + $image = "agent0ai/agent-zero:$($script:SelectedTag)" print_info 'Pulling Agent Zero image (this may take a moment)...' @@ -1059,4 +1340,4 @@ function main { } Show-Banner -main \ No newline at end of file +main From 7e54585573c8d8920d6e3be2af06d3d431c1d499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Le=C3=A3o?= Date: Sun, 5 Apr 2026 09:08:02 -0300 Subject: [PATCH 4/5] search for openclaw directory in more paths --- migration/migrate-openclaw.sh | 89 +++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/migration/migrate-openclaw.sh b/migration/migrate-openclaw.sh index 03f83aa..7918be0 100755 --- a/migration/migrate-openclaw.sh +++ b/migration/migrate-openclaw.sh @@ -54,6 +54,90 @@ Options: EOF } +expand_path_home() { + local value="${1:-}" + printf '%s' "${value/#\~/$HOME}" +} + +find_openclaw_dir() { + local candidate + + for candidate in "$HOME/.openclaw" "/opt/openclaw/.openclaw"; do + if [[ -d "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + done + + for candidate in /docker/openclaw-*/data/.openclaw; do + if [[ -d "${candidate}" ]]; then + printf '%s' "${candidate}" + return 0 + fi + done + + return 1 +} + +prompt_for_openclaw_dir() { + local candidate="" + + if [[ ! -t 0 ]]; then + error "OpenClaw directory was not found in the common locations and no interactive terminal is available." >&2 + error "Pass the .openclaw path explicitly: $0 [--include-auth-profiles] OPENCLAW_DIR [A0_USR_DIR]" >&2 + exit 1 + fi + + while true; do + printf 'Enter the absolute path to your .openclaw folder: ' >&2 + read -r candidate + + if [[ -z "${candidate}" ]]; then + warn "Please enter an absolute path." >&2 + continue + fi + + candidate="$(expand_path_home "${candidate}")" + + if [[ "${candidate}" != /* ]]; then + warn "The path must be absolute." >&2 + continue + fi + + if [[ ! -d "${candidate}" ]]; then + warn "Directory not found: ${candidate}" >&2 + continue + fi + + printf '%s' "${candidate}" + return 0 + done +} + +resolve_openclaw_dir() { + local explicit_dir="${1:-}" + local resolved="" + + if [[ -n "${explicit_dir}" ]]; then + resolved="$(expand_path_home "${explicit_dir}")" + if [[ -d "${resolved}" ]]; then + printf '%s' "${resolved}" + return 0 + fi + + warn "OpenClaw directory not found at explicit path: ${resolved}" >&2 + prompt_for_openclaw_dir + return 0 + fi + + if resolved="$(find_openclaw_dir)"; then + printf '%s' "${resolved}" + return 0 + fi + + prompt_for_openclaw_dir +} + MIGRATED=0 SKIPPED=0 WARNINGS=0 @@ -103,11 +187,10 @@ while [[ $# -gt 0 ]]; do esac done -OPENCLAW_DIR="${POSITIONAL_ARGS[0]:-$HOME/.openclaw}" +OPENCLAW_DIR="$(resolve_openclaw_dir "${POSITIONAL_ARGS[0]:-}")" A0_USR_DIR="${POSITIONAL_ARGS[1]:-/a0/usr}" -OPENCLAW_DIR="${OPENCLAW_DIR/#\~/$HOME}" -A0_USR_DIR="${A0_USR_DIR/#\~/$HOME}" +A0_USR_DIR="$(expand_path_home "${A0_USR_DIR}")" OPENCLAW_CONFIG="${OPENCLAW_DIR}/openclaw.json" OPENCLAW_ENV="${OPENCLAW_DIR}/.env" From 84c24aeb22b59cb0ef635f27e184b446a19452e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Le=C3=A3o?= Date: Sun, 5 Apr 2026 09:30:33 -0300 Subject: [PATCH 5/5] add windows openclaw migration script --- migration/migrate-openclaw.ps1 | 1728 ++++++++++++++++++++++++++++++++ 1 file changed, 1728 insertions(+) create mode 100644 migration/migrate-openclaw.ps1 diff --git a/migration/migrate-openclaw.ps1 b/migration/migrate-openclaw.ps1 new file mode 100644 index 0000000..94eb258 --- /dev/null +++ b/migration/migrate-openclaw.ps1 @@ -0,0 +1,1728 @@ +$ErrorActionPreference = 'Stop' + +<# +.SYNOPSIS + Migrate an OpenClaw installation to Agent Zero on Windows. + +.DESCRIPTION + PowerShell port of migrate-openclaw.sh with Windows-native path discovery. + +.PARAMETER IncludeAuthProfiles + Attempt to extract API-key-style secrets from auth-profiles.json. + +.PARAMETER OpenClawDir + Path to the OpenClaw state directory. If omitted, the script checks: + 1. $env:OPENCLAW_STATE_DIR + 2. $HOME\.openclaw + 3. $HOME\.openclaw-* + +.PARAMETER AgentZeroUsrDir + Path to Agent Zero's usr directory. If omitted, the script checks: + 1. $HOME\agent-zero\agent-zero\usr + 2. $HOME\agent-zero\*\usr + 3. Falls back to $HOME\agent-zero\agent-zero\usr + +.EXAMPLE + .\migrate-openclaw.ps1 -IncludeAuthProfiles + +.EXAMPLE + .\migrate-openclaw.ps1 'C:\Users\me\.openclaw' 'C:\Users\me\agent-zero\agent-zero\usr' +#> + +param( + [Parameter(Position = 0)] + [string]$OpenClawDir, + + [Parameter(Position = 1)] + [string]$AgentZeroUsrDir, + + [switch]$IncludeAuthProfiles, + + [switch]$Help +) + +$script:HomeDir = [Environment]::GetFolderPath('UserProfile') +$script:ScriptName = if ($PSCommandPath) { Split-Path -Leaf $PSCommandPath } else { 'migrate-openclaw.ps1' } +$script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$script:Migrated = 0 +$script:Skipped = 0 +$script:Warnings = 0 +$script:ReportInitialized = $false +$script:MigrationReport = '' +$script:PythonCommand = $null +$script:NodeCommand = $null +$script:KnownEnvKeys = @( + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'OPENROUTER_API_KEY', + 'GROQ_API_KEY', + 'MISTRAL_API_KEY', + 'DEEPSEEK_API_KEY', + 'TOGETHER_API_KEY', + 'PERPLEXITY_API_KEY', + 'XAI_API_KEY', + 'CEREBRAS_API_KEY', + 'SAMBANOVA_API_KEY' +) + +function Show-Usage { + @" +Usage: + .\migrate-openclaw.ps1 [-IncludeAuthProfiles] [OPENCLAW_DIR] [A0_USR_DIR] + +Options: + -IncludeAuthProfiles Attempt to extract API-key-style secrets from auth-profiles.json + -Help Show this help text +"@ | Write-Host +} + +function print_info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Cyan +} + +function print_ok { + param([string]$Message) + Write-Host "[ OK ] $Message" -ForegroundColor Green +} + +function print_warn { + param([string]$Message) + Write-Host "[WARN] $Message" -ForegroundColor Yellow +} + +function print_error { + param([string]$Message) + Write-Host "[ERR ] $Message" -ForegroundColor Red +} + +function Write-Header { + param([string]$Title) + Write-Host '' + Write-Host "=== $Title ===" -ForegroundColor White +} + +function Expand-UserPath { + param([string]$PathValue) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $PathValue + } + + if ($PathValue -eq '~') { + return $script:HomeDir + } + + if ($PathValue.StartsWith('~/') -or $PathValue.StartsWith('~\')) { + return (Join-Path $script:HomeDir $PathValue.Substring(2)) + } + + return $PathValue +} + +function Write-Utf8NoBom { + param( + [string]$Path, + [string]$Content + ) + + $parent = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Force -Path $parent *> $null + } + + [System.IO.File]::WriteAllText($Path, $Content, $script:Utf8NoBom) +} + +function Append-LineUtf8 { + param( + [string]$Path, + [string]$Line + ) + + $parent = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Force -Path $parent *> $null + } + + [System.IO.File]::AppendAllText($Path, $Line + [Environment]::NewLine, $script:Utf8NoBom) +} + +function report_line { + param([string]$Line = '') + + if ($script:ReportInitialized -and -not [string]::IsNullOrWhiteSpace($script:MigrationReport)) { + Append-LineUtf8 -Path $script:MigrationReport -Line $Line + } +} + +function log_migrated { + param([string]$Message) + $script:Migrated++ + print_ok $Message + report_line "- Migrated: $Message" +} + +function log_skipped { + param([string]$Message) + $script:Skipped++ + print_info "Skipped: $Message" + report_line "- Skipped: $Message" +} + +function log_warning { + param([string]$Message) + $script:Warnings++ + print_warn $Message + report_line "- Warning: $Message" +} + +function ConvertTo-OrderedMap { + param([object]$InputObject) + + $map = [ordered]@{} + + if ($null -eq $InputObject) { + return $map + } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + $map[$key] = $InputObject[$key] + } + return $map + } + + foreach ($prop in $InputObject.PSObject.Properties) { + $map[$prop.Name] = $prop.Value + } + + return $map +} + +function Get-ConfigPropertyValue { + param( + [object]$Object, + [string]$Name, + [object]$Default = $null + ) + + if ($null -eq $Object) { + return $Default + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { + return $Object[$Name] + } + return $Default + } + + $prop = $Object.PSObject.Properties[$Name] + if ($null -ne $prop) { + return $prop.Value + } + + return $Default +} + +function Get-NestedConfigValue { + param( + [object]$Object, + [string]$Path, + [object]$Default = $null + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $Object + } + + $current = $Object + foreach ($segment in ($Path.Trim('.').Split('.'))) { + if ([string]::IsNullOrWhiteSpace($segment)) { + continue + } + + if ($null -eq $current) { + return $Default + } + + if (($current -is [System.Collections.IList]) -and -not ($current -is [string])) { + if ($segment -notmatch '^\d+$') { + return $Default + } + $index = [int]$segment + if ($index -ge $current.Count) { + return $Default + } + $current = $current[$index] + continue + } + + $current = Get-ConfigPropertyValue -Object $current -Name $segment -Default $null + if ($null -eq $current) { + return $Default + } + } + + return $current +} + +function Test-IsConfigObject { + param([object]$Value) + return ($Value -is [System.Collections.IDictionary]) -or ($Value -is [pscustomobject]) +} + +function To-JsonString { + param( + [object]$Value, + [int]$Depth = 20 + ) + + return (($Value | ConvertTo-Json -Depth $Depth) + [Environment]::NewLine) +} + +function Write-JsonFile { + param( + [string]$Path, + [object]$Payload + ) + + Write-Utf8NoBom -Path $Path -Content (To-JsonString -Value $Payload -Depth 30) +} + +function Write-AgentManifest { + param( + [string]$Path, + [string]$Title, + [string]$Description + ) + + $payload = [ordered]@{ + title = $Title + description = $Description + } + Write-JsonFile -Path $Path -Payload $payload +} + +function First-MeaningfulLine { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return '' + } + + foreach ($line in [System.IO.File]::ReadAllLines($Path)) { + $stripped = ([regex]::Replace($line, '^#+\s*', '')).Trim() + if (-not [string]::IsNullOrWhiteSpace($stripped)) { + return $stripped + } + } + + return '' +} + +function Get-EnvironmentVariableValue { + param([string]$Name) + + foreach ($target in @('Process', 'User', 'Machine')) { + $value = [Environment]::GetEnvironmentVariable($Name, $target) + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + + return $null +} + +function Sanitize-AgentId { + param([string]$Value) + + $sanitized = [regex]::Replace(($Value | ForEach-Object { "$_" }), '[^A-Za-z0-9._-]+', '-').Trim('-') + if ([string]::IsNullOrWhiteSpace($sanitized)) { + return 'agent' + } + + return $sanitized +} + +function Map-EnvKey { + param([string]$SourceKey) + + switch ($SourceKey) { + 'OPENAI_API_KEY' { return 'API_KEY_OPENAI' } + 'ANTHROPIC_API_KEY' { return 'API_KEY_ANTHROPIC' } + { $_ -in @('GEMINI_API_KEY', 'GOOGLE_API_KEY') } { return 'API_KEY_GOOGLE' } + 'OPENROUTER_API_KEY' { return 'API_KEY_OPENROUTER' } + 'GROQ_API_KEY' { return 'API_KEY_GROQ' } + 'MISTRAL_API_KEY' { return 'API_KEY_MISTRAL' } + 'DEEPSEEK_API_KEY' { return 'API_KEY_DEEPSEEK' } + 'TOGETHER_API_KEY' { return 'API_KEY_TOGETHER' } + 'PERPLEXITY_API_KEY' { return 'API_KEY_PERPLEXITYAI' } + 'XAI_API_KEY' { return 'API_KEY_XAI' } + 'CEREBRAS_API_KEY' { return 'API_KEY_CEREBRAS' } + 'SAMBANOVA_API_KEY' { return 'API_KEY_SAMBANOVA' } + default { return $null } + } +} + +function Ensure-EnvKey { + param( + [string]$SourceKey, + [string]$Value, + [string]$SourceLabel, + [string]$TargetEnvPath + ) + + $targetKey = Map-EnvKey -SourceKey $SourceKey + if ([string]::IsNullOrWhiteSpace($targetKey) -or [string]::IsNullOrWhiteSpace($Value)) { + return + } + + $current = '' + if (Test-Path -LiteralPath $TargetEnvPath) { + $current = Get-Content -LiteralPath $TargetEnvPath -Raw + } + + $escapedTargetKey = [regex]::Escape($targetKey) + if ($current -match "(?m)^${escapedTargetKey}=") { + log_skipped "$targetKey already set in $TargetEnvPath" + return + } + + Append-LineUtf8 -Path $TargetEnvPath -Line "$targetKey=$Value" + log_migrated "$SourceKey -> $targetKey ($SourceLabel)" +} + +function Trim-WrappingQuotes { + param([string]$Value) + + if ($null -eq $Value) { + return '' + } + + $text = "$Value" + if ($text.Length -ge 2) { + if (($text.StartsWith('"') -and $text.EndsWith('"')) -or ($text.StartsWith("'") -and $text.EndsWith("'"))) { + return $text.Substring(1, $text.Length - 2) + } + } + + return $text +} + +function Get-EnvFileValues { + param([string]$Path) + + $values = [ordered]@{} + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $values + } + + foreach ($rawLine in [System.IO.File]::ReadAllLines($Path)) { + $line = $rawLine.Trim() + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#') -or -not $line.Contains('=')) { + continue + } + + $parts = $line.Split('=', 2) + $key = $parts[0].Trim() + $value = Trim-WrappingQuotes -Value $parts[1].Trim() + if (-not [string]::IsNullOrWhiteSpace($key)) { + $values[$key] = $value + } + } + + return $values +} + +function Get-ConfigEnvRefs { + param([string]$RawText) + + if ([string]::IsNullOrWhiteSpace($RawText)) { + return @() + } + + return @([regex]::Matches($RawText, '\$\{([A-Za-z_][A-Za-z0-9_]*)\}') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique) +} + +function Get-KnownEnvPairsFromObject { + param( + [object]$Object, + [string[]]$CandidateKeys + ) + + $results = New-Object System.Collections.Generic.List[object] + $seen = @{} + $candidateSet = @{} + foreach ($candidate in $CandidateKeys) { + $candidateSet[$candidate] = $true + } + + function Visit-Node { + param([object]$Node) + + if ($null -eq $Node) { + return + } + + if ($Node -is [System.Collections.IDictionary]) { + foreach ($key in $Node.Keys) { + $value = $Node[$key] + if ($candidateSet.ContainsKey([string]$key) -and $value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) { + $signature = "$key`n$value" + if (-not $seen.ContainsKey($signature)) { + $seen[$signature] = $true + $results.Add([pscustomobject]@{ + Key = [string]$key + Value = [string]$value + }) + } + } + Visit-Node -Node $value + } + return + } + + if ($Node -is [pscustomobject]) { + foreach ($prop in $Node.PSObject.Properties) { + $value = $prop.Value + if ($candidateSet.ContainsKey([string]$prop.Name) -and $value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) { + $signature = "$($prop.Name)`n$value" + if (-not $seen.ContainsKey($signature)) { + $seen[$signature] = $true + $results.Add([pscustomobject]@{ + Key = [string]$prop.Name + Value = [string]$value + }) + } + } + Visit-Node -Node $value + } + return + } + + if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) { + foreach ($item in $Node) { + Visit-Node -Node $item + } + } + } + + Visit-Node -Node $Object + return @($results) +} + +function Get-DirectoryFingerprint { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return @() + } + + $resolved = (Resolve-Path -LiteralPath $Path).Path + $files = Get-ChildItem -LiteralPath $resolved -Recurse -File | Sort-Object FullName + $lines = New-Object System.Collections.Generic.List[string] + foreach ($file in $files) { + $relative = $file.FullName.Substring($resolved.Length).TrimStart('\', '/') + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + $lines.Add("$relative`t$($file.Length)`t$hash") + } + return @($lines) +} + +function Directories-Equal { + param( + [string]$LeftPath, + [string]$RightPath + ) + + $left = Get-DirectoryFingerprint -Path $LeftPath + $right = Get-DirectoryFingerprint -Path $RightPath + + if ($left.Count -ne $right.Count) { + return $false + } + + for ($i = 0; $i -lt $left.Count; $i++) { + if ($left[$i] -ne $right[$i]) { + return $false + } + } + + return $true +} + +function Copy-DirectoryWithCollisionHandling { + param( + [string]$SourceDir, + [string]$TargetDir, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $TargetDir -PathType Container)) { + $parent = Split-Path -Parent $TargetDir + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Force -Path $parent *> $null + } + Copy-Item -LiteralPath $SourceDir -Destination $TargetDir -Recurse + log_migrated $Label + return + } + + if (Directories-Equal -LeftPath $SourceDir -RightPath $TargetDir) { + log_skipped "$Label already exists with identical contents" + } + else { + log_warning "$Label already exists with different contents: $TargetDir" + } +} + +function ConvertTo-List { + param([object]$Value) + + if ($null -eq $Value) { + return @() + } + + if (($Value -is [System.Collections.IEnumerable]) -and -not ($Value -is [string]) -and -not ($Value -is [System.Collections.IDictionary]) -and -not ($Value -is [pscustomobject])) { + return @($Value) + } + + return @($Value) +} + +function Normalize-AllowedUsers { + param([object]$Raw) + + if ($null -eq $Raw -or ($Raw -is [System.Collections.IDictionary]) -or ($Raw -is [pscustomobject])) { + return @() + } + + $result = New-Object System.Collections.Generic.List[string] + foreach ($item in (ConvertTo-List -Value $Raw)) { + $value = ([string]$item).Replace('telegram:', '').Replace('tg:', '').Trim() + if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne '*' -and -not $result.Contains($value)) { + $null = $result.Add($value) + } + } + + return @($result) +} + +function Normalize-GroupMode { + param([object]$RawGroups) + + if ($RawGroups -is [bool] -and -not $RawGroups) { + return 'off' + } + + if (-not (Test-IsConfigObject -Value $RawGroups)) { + return 'mention' + } + + $groupMap = ConvertTo-OrderedMap -InputObject $RawGroups + if ($groupMap.Count -eq 0) { + return 'mention' + } + + $keys = @('*') + @($groupMap.Keys) + foreach ($key in $keys) { + $entry = Get-ConfigPropertyValue -Object $RawGroups -Name ([string]$key) -Default $null + if (-not (Test-IsConfigObject -Value $entry)) { + continue + } + + $enabled = Get-ConfigPropertyValue -Object $entry -Name 'enabled' -Default $null + if ($enabled -is [bool] -and -not $enabled) { + return 'off' + } + + $requireMention = Get-ConfigPropertyValue -Object $entry -Name 'requireMention' -Default $null + if ($requireMention -is [bool] -and -not $requireMention) { + return 'all' + } + if ($requireMention -is [bool] -and $requireMention) { + return 'mention' + } + } + + return 'mention' +} + +function Get-WebhookFields { + param([object]$Raw) + + if (-not (Test-IsConfigObject -Value $Raw)) { + return [ordered]@{ + mode = 'polling' + webhook_url = '' + webhook_secret = '' + } + } + + $enabled = Get-ConfigPropertyValue -Object $Raw -Name 'enabled' -Default $null + if ($enabled -is [bool] -and -not $enabled) { + return [ordered]@{ + mode = 'polling' + webhook_url = '' + webhook_secret = '' + } + } + + $url = [string](Get-ConfigPropertyValue -Object $Raw -Name 'url' -Default '') + if ([string]::IsNullOrWhiteSpace($url)) { + $url = [string](Get-ConfigPropertyValue -Object $Raw -Name 'webhookUrl' -Default '') + } + if ([string]::IsNullOrWhiteSpace($url)) { + $url = [string](Get-ConfigPropertyValue -Object $Raw -Name 'baseUrl' -Default '') + } + + $secret = [string](Get-ConfigPropertyValue -Object $Raw -Name 'secret' -Default '') + if ([string]::IsNullOrWhiteSpace($secret)) { + $secret = [string](Get-ConfigPropertyValue -Object $Raw -Name 'webhookSecret' -Default '') + } + + if (-not [string]::IsNullOrWhiteSpace($url)) { + return [ordered]@{ + mode = 'webhook' + webhook_url = $url + webhook_secret = $secret + } + } + + return [ordered]@{ + mode = 'polling' + webhook_url = '' + webhook_secret = '' + } +} + +function Build-TelegramBot { + param( + [string]$Name, + [object]$Source, + [object]$TelegramConfig, + [System.Collections.IDictionary]$EnvValues + ) + + $warnings = New-Object System.Collections.Generic.List[string] + if (-not (Test-IsConfigObject -Value $Source)) { + $warnings.Add("telegram account '$Name' is not an object; skipped") + return [ordered]@{ + bot = $null + warnings = @($warnings) + } + } + + $token = [string](Get-ConfigPropertyValue -Object $Source -Name 'botToken' -Default '') + if ([string]::IsNullOrWhiteSpace($token)) { + $token = [string](Get-ConfigPropertyValue -Object $TelegramConfig -Name 'botToken' -Default '') + } + if ([string]::IsNullOrWhiteSpace($token) -and $EnvValues.Contains('TELEGRAM_BOT_TOKEN')) { + $token = [string]$EnvValues['TELEGRAM_BOT_TOKEN'] + } + + if ([string]::IsNullOrWhiteSpace($token) -or $token.Contains('${')) { + $warnings.Add("telegram account '$Name' has no concrete bot token; skipped") + return [ordered]@{ + bot = $null + warnings = @($warnings) + } + } + + $webhookSource = Get-ConfigPropertyValue -Object $Source -Name 'webhook' -Default $null + if ($null -eq $webhookSource) { + $webhookSource = Get-ConfigPropertyValue -Object $TelegramConfig -Name 'webhook' -Default $null + } + $webhook = Get-WebhookFields -Raw $webhookSource + + $allowFrom = Get-ConfigPropertyValue -Object $Source -Name 'allowFrom' -Default $null + if ($null -eq $allowFrom) { + $allowFrom = Get-ConfigPropertyValue -Object $TelegramConfig -Name 'allowFrom' -Default @() + } + + $groups = Get-ConfigPropertyValue -Object $Source -Name 'groups' -Default $null + if ($null -eq $groups) { + $groups = Get-ConfigPropertyValue -Object $TelegramConfig -Name 'groups' -Default @{} + } + + $bot = [ordered]@{ + name = $Name + enabled = $true + token = $token + mode = $webhook['mode'] + webhook_url = $webhook['webhook_url'] + webhook_secret = $webhook['webhook_secret'] + allowed_users = @(Normalize-AllowedUsers -Raw $allowFrom) + group_mode = Normalize-GroupMode -RawGroups $groups + } + + return [ordered]@{ + bot = $bot + warnings = @($warnings) + } +} + +function Build-TelegramPayload { + param( + [object]$ConfigObject, + [System.Collections.IDictionary]$EnvValues + ) + + $channels = Get-ConfigPropertyValue -Object $ConfigObject -Name 'channels' -Default $null + $telegram = Get-ConfigPropertyValue -Object $channels -Name 'telegram' -Default @{} + if (-not (Test-IsConfigObject -Value $telegram)) { + $telegram = @{} + } + + $bots = New-Object System.Collections.Generic.List[object] + $warnings = New-Object System.Collections.Generic.List[string] + $notes = New-Object System.Collections.Generic.List[string] + + $accounts = Get-ConfigPropertyValue -Object $telegram -Name 'accounts' -Default $null + if (Test-IsConfigObject -Value $accounts) { + foreach ($entry in (ConvertTo-OrderedMap -InputObject $accounts).GetEnumerator()) { + $built = Build-TelegramBot -Name ([string]$entry.Key) -Source $entry.Value -TelegramConfig $telegram -EnvValues $EnvValues + foreach ($warning in @($built['warnings'])) { + if (-not [string]::IsNullOrWhiteSpace($warning)) { + $warnings.Add($warning) + } + } + if ($null -ne $built['bot']) { + $bots.Add($built['bot']) + } + } + } + else { + $built = Build-TelegramBot -Name 'default' -Source $telegram -TelegramConfig $telegram -EnvValues $EnvValues + foreach ($warning in @($built['warnings'])) { + if (-not [string]::IsNullOrWhiteSpace($warning)) { + $warnings.Add($warning) + } + } + if ($null -ne $built['bot']) { + $bots.Add($built['bot']) + } + } + + $dmPolicy = [string](Get-ConfigPropertyValue -Object $telegram -Name 'dmPolicy' -Default '') + if (-not [string]::IsNullOrWhiteSpace($dmPolicy)) { + $notes.Add("OpenClaw dmPolicy was '$dmPolicy' and was not mapped directly.") + } + + if ($null -ne (Get-ConfigPropertyValue -Object $telegram -Name 'bindings' -Default $null)) { + $notes.Add('OpenClaw Telegram bindings were not migrated; review channel routing manually.') + } + + if ($null -ne (Get-ConfigPropertyValue -Object $telegram -Name 'pairing' -Default $null)) { + $notes.Add('OpenClaw Telegram pairing behavior was not migrated.') + } + + return [ordered]@{ + bots = @($bots) + warnings = @($warnings) + notes = @($notes) + } +} + +function Get-PythonCommand { + if ($script:PythonCommand) { + return $script:PythonCommand + } + + if (Get-Command python -ErrorAction SilentlyContinue) { + $script:PythonCommand = [ordered]@{ + Name = 'python' + Args = @() + } + return $script:PythonCommand + } + + if (Get-Command python3 -ErrorAction SilentlyContinue) { + $script:PythonCommand = [ordered]@{ + Name = 'python3' + Args = @() + } + return $script:PythonCommand + } + + if (Get-Command py -ErrorAction SilentlyContinue) { + $probe = & py -3 -c 'import sys; print(sys.version_info[0])' 2>$null + if ($LASTEXITCODE -eq 0) { + $script:PythonCommand = [ordered]@{ + Name = 'py' + Args = @('-3') + } + return $script:PythonCommand + } + } + + return $null +} + +function Invoke-PythonCommand { + param([string]$Code) + + $python = Get-PythonCommand + if ($null -eq $python) { + return [pscustomobject]@{ + ExitCode = 127 + Output = '' + } + } + + $output = & $python['Name'] @($python['Args']) -c $Code 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = (($output | ForEach-Object { "$_" }) -join [Environment]::NewLine) + } +} + +function Invoke-PythonFileCommand { + param( + [string]$Code, + [string[]]$Arguments + ) + + $python = Get-PythonCommand + if ($null -eq $python) { + return [pscustomobject]@{ + ExitCode = 127 + Output = '' + } + } + + $output = & $python['Name'] @($python['Args']) -c $Code @Arguments 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = (($output | ForEach-Object { "$_" }) -join [Environment]::NewLine) + } +} + +function Get-NodeCommand { + if ($script:NodeCommand) { + return $script:NodeCommand + } + + if (Get-Command node -ErrorAction SilentlyContinue) { + $script:NodeCommand = 'node' + } + + return $script:NodeCommand +} + +function Invoke-NodeCommand { + param( + [string]$Code, + [string[]]$Arguments + ) + + $node = Get-NodeCommand + if ([string]::IsNullOrWhiteSpace($node)) { + return [pscustomobject]@{ + ExitCode = 127 + Output = '' + } + } + + $output = & $node -e $Code @Arguments 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = (($output | ForEach-Object { "$_" }) -join [Environment]::NewLine) + } +} + +function Test-PythonJson5Support { + $result = Invoke-PythonCommand -Code 'import json5' + return ($result.ExitCode -eq 0) +} + +function Test-NodeJson5Support { + $result = Invoke-NodeCommand -Code 'require("json5")' -Arguments @() + return ($result.ExitCode -eq 0) +} + +function Parse-ConfigJson5WithPython { + param([string]$Path) + + $code = @' +import json +import pathlib +import sys +import json5 + +path = pathlib.Path(sys.argv[1]) +data = json5.loads(path.read_text(encoding="utf-8")) +sys.stdout.write(json.dumps(data)) +'@ + + $result = Invoke-PythonFileCommand -Code $code -Arguments @($Path) + if ($result.ExitCode -ne 0) { + throw "Python json5 parse failed: $($result.Output)" + } + return $result.Output +} + +function Parse-ConfigJson5WithNode { + param([string]$Path) + + $code = @' +const fs = require("fs"); +const JSON5 = require("json5"); +const filePath = process.argv[1]; +const raw = fs.readFileSync(filePath, "utf8"); +process.stdout.write(JSON.stringify(JSON5.parse(raw))); +'@ + + $result = Invoke-NodeCommand -Code $code -Arguments @($Path) + if ($result.ExitCode -ne 0) { + throw "Node json5 parse failed: $($result.Output)" + } + return $result.Output +} + +function Select-PathFromCandidates { + param( + [string]$Label, + [string[]]$Candidates + ) + + if ($Candidates.Count -eq 0) { + return $null + } + + if ($Candidates.Count -eq 1 -or -not [Environment]::UserInteractive) { + return $Candidates[0] + } + + Write-Host '' + Write-Host "Multiple $Label directories were found:" -ForegroundColor White + for ($i = 0; $i -lt $Candidates.Count; $i++) { + Write-Host (" {0}. {1}" -f ($i + 1), $Candidates[$i]) + } + + while ($true) { + $selection = Read-Host "Choose a number for the $Label directory" + if ([string]::IsNullOrWhiteSpace($selection)) { + return $Candidates[0] + } + if ($selection -match '^\d+$') { + $index = [int]$selection - 1 + if ($index -ge 0 -and $index -lt $Candidates.Count) { + return $Candidates[$index] + } + } + print_warn 'Invalid selection.' + } +} + +function Prompt-ForAbsoluteDirectory { + param([string]$Prompt) + + if (-not [Environment]::UserInteractive) { + throw "Directory was not found automatically and no interactive terminal is available. Pass the path explicitly." + } + + while ($true) { + $candidate = Read-Host $Prompt + $candidate = Expand-UserPath -PathValue $candidate + + if ([string]::IsNullOrWhiteSpace($candidate)) { + print_warn 'Please enter an absolute path.' + continue + } + + if (-not [System.IO.Path]::IsPathRooted($candidate)) { + print_warn 'The path must be absolute.' + continue + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + print_warn "Directory not found: $candidate" + continue + } + + return $candidate + } +} + +function Get-OpenClawCandidatePaths { + $seen = @{} + $candidates = New-Object System.Collections.Generic.List[string] + + $envStateDir = Expand-UserPath -PathValue $env:OPENCLAW_STATE_DIR + if (-not [string]::IsNullOrWhiteSpace($envStateDir) -and (Test-Path -LiteralPath $envStateDir -PathType Container)) { + $seen[$envStateDir] = $true + $candidates.Add($envStateDir) + } + + $defaultDir = Join-Path $script:HomeDir '.openclaw' + if (Test-Path -LiteralPath $defaultDir -PathType Container -and -not $seen.ContainsKey($defaultDir)) { + $seen[$defaultDir] = $true + $candidates.Add($defaultDir) + } + + if (Test-Path -LiteralPath $script:HomeDir -PathType Container) { + $profileDirs = Get-ChildItem -LiteralPath $script:HomeDir -Directory | Where-Object { $_.Name -like '.openclaw-*' } | Sort-Object Name + foreach ($dir in $profileDirs) { + if (-not $seen.ContainsKey($dir.FullName)) { + $seen[$dir.FullName] = $true + $candidates.Add($dir.FullName) + } + } + } + + return @($candidates) +} + +function Resolve-OpenClawDir { + param([string]$ExplicitDir) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitDir)) { + $resolved = Expand-UserPath -PathValue $ExplicitDir + if (Test-Path -LiteralPath $resolved -PathType Container) { + return $resolved + } + + print_warn "OpenClaw directory not found at explicit path: $resolved" + return (Prompt-ForAbsoluteDirectory -Prompt 'Enter the absolute path to your .openclaw folder') + } + + $candidates = Get-OpenClawCandidatePaths + if ($candidates.Count -gt 0) { + return (Select-PathFromCandidates -Label 'OpenClaw' -Candidates $candidates) + } + + return (Prompt-ForAbsoluteDirectory -Prompt 'Enter the absolute path to your .openclaw folder') +} + +function Get-AgentZeroUsrCandidates { + $installRoot = Join-Path $script:HomeDir 'agent-zero' + $seen = @{} + $candidates = New-Object System.Collections.Generic.List[string] + + $defaultUsr = Join-Path $installRoot 'agent-zero\usr' + if (Test-Path -LiteralPath $defaultUsr -PathType Container) { + $seen[$defaultUsr] = $true + $candidates.Add($defaultUsr) + } + + if (Test-Path -LiteralPath $installRoot -PathType Container) { + foreach ($instanceDir in (Get-ChildItem -LiteralPath $installRoot -Directory | Sort-Object Name)) { + $usrDir = Join-Path $instanceDir.FullName 'usr' + if (Test-Path -LiteralPath $usrDir -PathType Container -and -not $seen.ContainsKey($usrDir)) { + $seen[$usrDir] = $true + $candidates.Add($usrDir) + } + } + } + + return @($candidates) +} + +function Resolve-AgentZeroUsrDir { + param([string]$ExplicitDir) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitDir)) { + return (Expand-UserPath -PathValue $ExplicitDir) + } + + $candidates = Get-AgentZeroUsrCandidates + if ($candidates.Count -gt 0) { + return (Select-PathFromCandidates -Label 'Agent Zero usr' -Candidates $candidates) + } + + return (Join-Path (Join-Path $script:HomeDir 'agent-zero') 'agent-zero\usr') +} + +if ($Help) { + Show-Usage + exit 0 +} + +$OpenClawDir = Resolve-OpenClawDir -ExplicitDir $OpenClawDir +$AgentZeroUsrDir = Resolve-AgentZeroUsrDir -ExplicitDir $AgentZeroUsrDir + +$OPENCLAW_CONFIG = Join-Path $OpenClawDir 'openclaw.json' +$OPENCLAW_ENV = Join-Path $OpenClawDir '.env' +$OPENCLAW_AUTH_PROFILES = Join-Path $OpenClawDir 'auth-profiles.json' +$A0_ENV = Join-Path $AgentZeroUsrDir '.env' +$MIGRATION_LOG = Join-Path $AgentZeroUsrDir 'openclaw-migration.log' +$script:MigrationReport = Join-Path $AgentZeroUsrDir 'openclaw-migration-report.md' +$MIGRATION_WORKDIR_ROOT = Join-Path $AgentZeroUsrDir 'workdir\openclaw-migration' +$KNOWLEDGE_DIR = Join-Path $AgentZeroUsrDir 'knowledge\custom\openclaw' +$MEMORY_KNOWLEDGE_DIR = Join-Path $AgentZeroUsrDir 'knowledge\custom\openclaw-memory' + +Write-Header 'OpenClaw -> Agent Zero Migration' +Write-Host '' +print_info "OpenClaw dir : $OpenClawDir" +print_info "Agent Zero : $AgentZeroUsrDir" +Write-Host '' + +if (-not (Test-Path -LiteralPath $OpenClawDir -PathType Container)) { + print_error "OpenClaw directory not found: $OpenClawDir" + exit 1 +} + +New-Item -ItemType Directory -Force -Path $AgentZeroUsrDir *> $null +if (-not (Test-Path -LiteralPath $A0_ENV -PathType Leaf)) { + Write-Utf8NoBom -Path $A0_ENV -Content '' +} + +$reportIntro = @( + '# OpenClaw -> Agent Zero Migration Report', + '', + "- Date: $([DateTimeOffset]::Now.ToString('o'))", + "- Source: $OpenClawDir", + "- Target: $AgentZeroUsrDir", + "- Promptinclude workdir root: $MIGRATION_WORKDIR_ROOT", + '', + '## Notes', + '', + "- Agent Zero profiles are generated from OpenClaw agents, but they do not preserve OpenClaw's full auth/session/channel isolation model.", + '- Promptinclude files are written to a generated workdir tree. Agent Zero loads them only when the active workdir or project points there.', + '', + '## Actions', + '' +) +Write-Utf8NoBom -Path $script:MigrationReport -Content (($reportIntro -join [Environment]::NewLine) + [Environment]::NewLine) +$script:ReportInitialized = $true + +$ConfigObject = [ordered]@{} +$ConfigParseMode = 'not-used' +$ConfigRawText = '' + +if (Test-Path -LiteralPath $OPENCLAW_CONFIG -PathType Leaf) { + $ConfigRawText = Get-Content -LiteralPath $OPENCLAW_CONFIG -Raw + try { + $ConfigObject = $ConfigRawText | ConvertFrom-Json + $ConfigParseMode = 'powershell-json' + print_ok 'Parsed openclaw.json with strict JSON parser' + report_line "- Config parser: $ConfigParseMode" + } + catch { + if (Test-PythonJson5Support) { + $parsedJson = Parse-ConfigJson5WithPython -Path $OPENCLAW_CONFIG + $ConfigObject = $parsedJson | ConvertFrom-Json + $ConfigParseMode = 'python-json5' + print_ok 'Parsed openclaw.json with Python json5 parser' + report_line "- Config parser: $ConfigParseMode" + } + elseif (Test-NodeJson5Support) { + $parsedJson = Parse-ConfigJson5WithNode -Path $OPENCLAW_CONFIG + $ConfigObject = $parsedJson | ConvertFrom-Json + $ConfigParseMode = 'node-json5' + print_ok 'Parsed openclaw.json with Node json5 parser' + report_line "- Config parser: $ConfigParseMode" + } + else { + print_error "Could not parse $OPENCLAW_CONFIG." + print_error 'The file appears to require JSON5 support, but no JSON5-capable parser is available.' + print_error "Install the Python package 'json5' or a Node runtime with the 'json5' package, then rerun." + exit 1 + } + } +} +else { + print_warn "No openclaw.json found at $OPENCLAW_CONFIG" + print_warn 'Will still attempt to migrate workspace files and .env' + report_line '- Config parser: config file missing' +} + +$envValues = Get-EnvFileValues -Path $OPENCLAW_ENV + +Write-Header 'Step 1: API Keys' + +if (Test-Path -LiteralPath $OPENCLAW_ENV -PathType Leaf) { + print_info "Reading $OPENCLAW_ENV" + foreach ($entry in $envValues.GetEnumerator()) { + Ensure-EnvKey -SourceKey ([string]$entry.Key) -Value ([string]$entry.Value) -SourceLabel '.env' -TargetEnvPath $A0_ENV + } +} +else { + log_skipped "No .env file found at $OPENCLAW_ENV" +} + +if (Test-Path -LiteralPath $OPENCLAW_CONFIG -PathType Leaf) { + foreach ($refName in (Get-ConfigEnvRefs -RawText $ConfigRawText)) { + $targetKey = Map-EnvKey -SourceKey $refName + if ([string]::IsNullOrWhiteSpace($targetKey)) { + continue + } + + $value = Get-EnvironmentVariableValue -Name $refName + if (-not [string]::IsNullOrWhiteSpace($value)) { + Ensure-EnvKey -SourceKey $refName -Value $value -SourceLabel 'PowerShell environment (referenced by openclaw.json)' -TargetEnvPath $A0_ENV + } + elseif ($envValues.Contains($refName)) { + continue + } + else { + log_warning "Config references $refName; set $targetKey manually if needed" + } + } +} + +if ($IncludeAuthProfiles) { + if (Test-Path -LiteralPath $OPENCLAW_AUTH_PROFILES -PathType Leaf) { + try { + $authProfilesObject = (Get-Content -LiteralPath $OPENCLAW_AUTH_PROFILES -Raw) | ConvertFrom-Json + foreach ($pair in (Get-KnownEnvPairsFromObject -Object $authProfilesObject -CandidateKeys $script:KnownEnvKeys)) { + Ensure-EnvKey -SourceKey $pair.Key -Value $pair.Value -SourceLabel 'auth-profiles.json' -TargetEnvPath $A0_ENV + } + } + catch { + log_warning "Could not parse auth-profiles.json: $($_.Exception.Message)" + } + } + else { + log_skipped "No auth-profiles.json found at $OPENCLAW_AUTH_PROFILES" + } +} +else { + log_skipped 'auth-profiles.json scan disabled (use -IncludeAuthProfiles to enable)' +} + +Write-Header 'Step 2: Discover Agents' + +$agentsList = Get-NestedConfigValue -Object $ConfigObject -Path 'agents.list' -Default @() +$defaultWorkspace = [string](Get-NestedConfigValue -Object $ConfigObject -Path 'agents.defaults.workspace' -Default (Join-Path $OpenClawDir 'workspace')) +$defaultWorkspace = Expand-UserPath -PathValue $defaultWorkspace + +$agentIds = @() +$agentNames = @() +$agentWorkspaces = @() +$agentProfileIds = @() + +if (($agentsList -is [System.Collections.IEnumerable]) -and -not ($agentsList -is [string])) { + $index = 0 + foreach ($agent in $agentsList) { + if (-not (Test-IsConfigObject -Value $agent)) { + continue + } + $aid = [string](Get-ConfigPropertyValue -Object $agent -Name 'id' -Default "agent$index") + $name = [string](Get-ConfigPropertyValue -Object $agent -Name 'name' -Default $aid) + $workspace = [string](Get-ConfigPropertyValue -Object $agent -Name 'workspace' -Default '') + $agentIds += $aid + $agentNames += $name + $agentWorkspaces += $workspace + $index++ + } +} + +if ($agentIds.Count -eq 0) { + $agentIds = @('main') + $agentNames = @('Main') + $agentWorkspaces = @('') +} + +$usedProfileIds = @() +for ($i = 0; $i -lt $agentIds.Count; $i++) { + $aid = $agentIds[$i] + $workspace = $agentWorkspaces[$i] + + if ([string]::IsNullOrWhiteSpace($workspace)) { + $workspaceCandidate = Join-Path $OpenClawDir "workspace-$aid" + if (Test-Path -LiteralPath $workspaceCandidate -PathType Container) { + $workspace = $workspaceCandidate + } + elseif (Test-Path -LiteralPath $defaultWorkspace -PathType Container) { + $workspace = $defaultWorkspace + } + else { + $workspace = Join-Path $OpenClawDir 'workspace' + } + } + else { + $workspace = Expand-UserPath -PathValue $workspace + } + + $agentWorkspaces[$i] = $workspace + + $baseProfileId = Sanitize-AgentId -Value $aid + $profileId = $baseProfileId + $suffix = 2 + while ($usedProfileIds -contains $profileId) { + $profileId = "$baseProfileId-$suffix" + $suffix++ + } + $usedProfileIds += $profileId + $agentProfileIds += $profileId +} + +print_info "Found $($agentIds.Count) agent(s):" +for ($i = 0; $i -lt $agentIds.Count; $i++) { + $aid = $agentIds[$i] + $name = $agentNames[$i] + $workspace = $agentWorkspaces[$i] + $profileId = $agentProfileIds[$i] + if (Test-Path -LiteralPath $workspace -PathType Container) { + Write-Host " * $aid ($name) -> $workspace [profile: $profileId] OK" + } + else { + Write-Host " * $aid ($name) -> $workspace [profile: $profileId] MISSING" + } +} + +Write-Header 'Step 3: Agent Profiles and Prompt Content' + +for ($i = 0; $i -lt $agentIds.Count; $i++) { + $aid = $agentIds[$i] + $workspace = $agentWorkspaces[$i] + $name = $agentNames[$i] + $profileId = $agentProfileIds[$i] + + if (-not (Test-Path -LiteralPath $workspace -PathType Container)) { + log_warning "Workspace not found for agent '$aid': $workspace" + continue + } + + $agentDir = Join-Path $AgentZeroUsrDir "agents\$profileId" + $promptsDir = Join-Path $agentDir 'prompts' + $agentSkillsDir = Join-Path $agentDir 'skills' + $agentWorkdir = Join-Path $MIGRATION_WORKDIR_ROOT $profileId + New-Item -ItemType Directory -Force -Path $promptsDir, $agentSkillsDir, $agentWorkdir *> $null + + print_info "Migrating OpenClaw agent '$aid' into Agent Zero profile '$profileId'" + + $description = "Generated from OpenClaw agent '$aid'" + $identityPath = Join-Path $workspace 'IDENTITY.md' + if (Test-Path -LiteralPath $identityPath -PathType Leaf) { + $firstLine = First-MeaningfulLine -Path $identityPath + if (-not [string]::IsNullOrWhiteSpace($firstLine)) { + $description = $firstLine + } + } + + $agentManifestPath = Join-Path $agentDir 'agent.json' + if (-not (Test-Path -LiteralPath $agentManifestPath -PathType Leaf)) { + Write-AgentManifest -Path $agentManifestPath -Title $name -Description $description + log_migrated "agent.json for profile '$profileId'" + } + else { + log_skipped "agent.json already exists for profile '$profileId'" + } + + $roleSource = Join-Path $workspace 'SOUL.md' + $roleTarget = Join-Path $promptsDir 'agent.system.main.role.md' + if (Test-Path -LiteralPath $roleSource -PathType Leaf) { + if (-not (Test-Path -LiteralPath $roleTarget -PathType Leaf)) { + $content = @( + '# Agent Role', + '', + '> Migrated from OpenClaw SOUL.md.', + '', + (Get-Content -LiteralPath $roleSource -Raw) + ) -join [Environment]::NewLine + Write-Utf8NoBom -Path $roleTarget -Content $content + log_migrated "SOUL.md -> agent.system.main.role.md (profile '$profileId')" + } + else { + log_skipped "agent.system.main.role.md already exists for profile '$profileId'" + } + } + + $promptincludeCreated = $false + + if (Test-Path -LiteralPath $identityPath -PathType Leaf) { + $target = Join-Path $agentWorkdir 'identity.promptinclude.md' + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + $content = @( + '# Agent Identity', + '', + '> Migrated from OpenClaw IDENTITY.md.', + '', + (Get-Content -LiteralPath $identityPath -Raw) + ) -join [Environment]::NewLine + Write-Utf8NoBom -Path $target -Content $content + log_migrated "IDENTITY.md -> $target" + $promptincludeCreated = $true + } + else { + log_skipped "identity.promptinclude.md already exists for profile '$profileId'" + } + } + + $userPath = Join-Path $workspace 'USER.md' + if (Test-Path -LiteralPath $userPath -PathType Leaf) { + $target = Join-Path $agentWorkdir 'user.promptinclude.md' + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + $content = @( + '# User Profile', + '', + '> Migrated from OpenClaw USER.md.', + '', + (Get-Content -LiteralPath $userPath -Raw) + ) -join [Environment]::NewLine + Write-Utf8NoBom -Path $target -Content $content + log_migrated "USER.md -> $target" + $promptincludeCreated = $true + } + else { + log_skipped "user.promptinclude.md already exists for profile '$profileId'" + } + } + + $memoryPath = Join-Path $workspace 'MEMORY.md' + if (Test-Path -LiteralPath $memoryPath -PathType Leaf) { + $target = Join-Path $agentWorkdir 'memory.promptinclude.md' + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + $content = @( + '# Long-Term Memory', + '', + '> Migrated from OpenClaw MEMORY.md.', + '> Agent Zero will load this only when the active workdir or project points to this folder.', + '', + (Get-Content -LiteralPath $memoryPath -Raw) + ) -join [Environment]::NewLine + Write-Utf8NoBom -Path $target -Content $content + log_migrated "MEMORY.md -> $target" + $promptincludeCreated = $true + } + else { + log_skipped "memory.promptinclude.md already exists for profile '$profileId'" + } + + $memoryKnowledgeTarget = Join-Path $MEMORY_KNOWLEDGE_DIR "$profileId\MEMORY.md" + if (-not (Test-Path -LiteralPath $memoryKnowledgeTarget -PathType Leaf)) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $memoryKnowledgeTarget) *> $null + Copy-Item -LiteralPath $memoryPath -Destination $memoryKnowledgeTarget + log_migrated "MEMORY.md -> knowledge\custom\openclaw-memory\$profileId\MEMORY.md" + } + else { + log_skipped "Memory knowledge file already exists for profile '$profileId'" + } + } + + $promptincludeReadme = Join-Path $agentWorkdir 'README.md' + if (-not (Test-Path -LiteralPath $promptincludeReadme -PathType Leaf)) { + $content = @( + '# OpenClaw Promptinclude Migration', + '', + ('This folder contains promptinclude files generated from OpenClaw workspace content for Agent Zero profile `' + $profileId + '`.' ), + '', + 'To have Agent Zero load these files with the `_promptinclude` plugin, set the active workdir or project to this folder.', + '', + ('- OpenClaw agent id: `' + $aid + '`'), + ('- Source workspace: `' + $workspace + '`') + ) -join [Environment]::NewLine + Write-Utf8NoBom -Path $promptincludeReadme -Content $content + log_migrated "Promptinclude README for profile '$profileId'" + } + else { + log_skipped "Promptinclude README already exists for profile '$profileId'" + } + + $specificsTarget = Join-Path $promptsDir 'agent.system.main.specifics.md' + $agentsMd = Join-Path $workspace 'AGENTS.md' + $toolsMd = Join-Path $workspace 'TOOLS.md' + if (-not (Test-Path -LiteralPath $specificsTarget -PathType Leaf)) { + if ((Test-Path -LiteralPath $agentsMd -PathType Leaf) -or (Test-Path -LiteralPath $toolsMd -PathType Leaf) -or (Test-Path -LiteralPath $identityPath -PathType Leaf) -or (Test-Path -LiteralPath $userPath -PathType Leaf) -or (Test-Path -LiteralPath $memoryPath -PathType Leaf)) { + $parts = New-Object System.Collections.Generic.List[string] + $parts.Add('# Agent Specifics') + $parts.Add('') + $parts.Add(('> Generated from OpenClaw workspace `' + $workspace + '`.')) + $parts.Add("> This Agent Zero profile preserves prompt content, but not OpenClaw auth/session/channel isolation.") + $parts.Add('') + + if (Test-Path -LiteralPath $agentsMd -PathType Leaf) { + $parts.Add('## Operating Instructions') + $parts.Add('') + $parts.Add((Get-Content -LiteralPath $agentsMd -Raw)) + $parts.Add('') + } + + if (Test-Path -LiteralPath $toolsMd -PathType Leaf) { + $parts.Add('## Local Tool Notes') + $parts.Add('') + $parts.Add((Get-Content -LiteralPath $toolsMd -Raw)) + $parts.Add('') + } + + $parts.Add('## Migration Notes') + $parts.Add('') + $parts.Add(('- Promptinclude files were written to `' + $agentWorkdir + '`.')) + $parts.Add('- To load `IDENTITY.md`, `USER.md`, and `MEMORY.md` automatically, point Agent Zero workdir or project to that folder.') + $parts.Add(('- `memory/*.md` files were copied into `' + [System.IO.Path]::Combine($KNOWLEDGE_DIR, $profileId) + '` for searchability.')) + if (Test-Path -LiteralPath $memoryPath -PathType Leaf) { + $parts.Add(('- `MEMORY.md` was also copied into `' + [System.IO.Path]::Combine($MEMORY_KNOWLEDGE_DIR, $profileId, 'MEMORY.md') + '` for reference.')) + } + + Write-Utf8NoBom -Path $specificsTarget -Content (($parts -join [Environment]::NewLine)) + log_migrated "agent.system.main.specifics.md for profile '$profileId'" + } + } + else { + log_skipped "agent.system.main.specifics.md already exists for profile '$profileId'" + } + + $memoryDir = Join-Path $workspace 'memory' + if (Test-Path -LiteralPath $memoryDir -PathType Container) { + $copiedCount = 0 + $profileKnowledgeDir = Join-Path $KNOWLEDGE_DIR $profileId + New-Item -ItemType Directory -Force -Path $profileKnowledgeDir *> $null + foreach ($mdFile in (Get-ChildItem -LiteralPath $memoryDir -Filter '*.md' -File)) { + $target = Join-Path $profileKnowledgeDir $mdFile.Name + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + Copy-Item -LiteralPath $mdFile.FullName -Destination $target + $copiedCount++ + } + } + + if ($copiedCount -gt 0) { + log_migrated "$copiedCount memory\*.md files -> knowledge\custom\openclaw\$profileId" + } + else { + log_skipped "No new memory\*.md files for profile '$profileId'" + } + } + + if ($promptincludeCreated) { + report_line "- Promptinclude workdir created for profile '$profileId': $agentWorkdir" + } +} + +Write-Header 'Step 4: Telegram' + +$telegramPluginDir = Join-Path $AgentZeroUsrDir 'plugins\_telegram_integration' +$telegramConfigPath = Join-Path $telegramPluginDir 'config.json' +$telegramNotesPath = Join-Path $telegramPluginDir 'openclaw-migration-notes.md' +New-Item -ItemType Directory -Force -Path $telegramPluginDir *> $null + +$telegramPayload = Build-TelegramPayload -ConfigObject $ConfigObject -EnvValues $envValues +$telegramBots = @($telegramPayload['bots']) + +if ($telegramBots.Count -gt 0) { + if (-not (Test-Path -LiteralPath $telegramConfigPath -PathType Leaf)) { + Write-JsonFile -Path $telegramConfigPath -Payload ([ordered]@{ bots = $telegramBots }) + log_migrated "Telegram config -> $telegramConfigPath" + } + else { + log_skipped "Telegram config already exists at $telegramConfigPath" + log_warning "Review existing Telegram config manually: $telegramConfigPath" + } + + $tgNotes = New-Object System.Collections.Generic.List[string] + $tgNotes.Add('# OpenClaw Telegram Migration Notes') + $tgNotes.Add('') + $tgNotes.Add(('- Generated from: `' + $OPENCLAW_CONFIG + '`')) + $tgNotes.Add(('- Output config: `' + $telegramConfigPath + '`')) + $tgNotes.Add("- Bot count migrated: $($telegramBots.Count)") + $tgNotes.Add('') + $tgNotes.Add('## Manual Review Items') + $tgNotes.Add('') + $tgNotes.Add('- Per-group policies are mapped conservatively to Agent Zero `group_mode`.') + $tgNotes.Add('- OpenClaw bindings, pairing, and DM policy do not map cleanly and require manual review.') + $tgNotes.Add('- Session history and channel routing are not migrated.') + $tgNotes.Add('') + $tgNotes.Add('## Payload Notes') + $tgNotes.Add('') + + $payloadNotes = @($telegramPayload['notes']) + $payloadWarnings = @($telegramPayload['warnings']) + if ($payloadNotes.Count -eq 0 -and $payloadWarnings.Count -eq 0) { + $tgNotes.Add('- No extra migration notes emitted.') + } + else { + foreach ($item in $payloadNotes) { + $tgNotes.Add("- $item") + } + foreach ($item in $payloadWarnings) { + $tgNotes.Add("- WARNING: $item") + } + } + + Write-Utf8NoBom -Path $telegramNotesPath -Content (($tgNotes -join [Environment]::NewLine) + [Environment]::NewLine) + log_migrated "Telegram migration notes -> $telegramNotesPath" + + foreach ($warning in @($telegramPayload['warnings'])) { + if (-not [string]::IsNullOrWhiteSpace($warning)) { + log_warning $warning + } + } +} +else { + log_skipped 'No Telegram bot tokens found to migrate' +} + +Write-Header 'Step 5: Skills' + +for ($i = 0; $i -lt $agentIds.Count; $i++) { + $workspace = $agentWorkspaces[$i] + $profileId = $agentProfileIds[$i] + $workspaceSkillsDir = Join-Path $workspace 'skills' + + if (-not (Test-Path -LiteralPath $workspaceSkillsDir -PathType Container)) { + continue + } + + foreach ($skillDir in (Get-ChildItem -LiteralPath $workspaceSkillsDir -Directory)) { + $target = Join-Path $AgentZeroUsrDir "agents\$profileId\skills\$($skillDir.Name)" + Copy-DirectoryWithCollisionHandling -SourceDir $skillDir.FullName -TargetDir $target -Label "Workspace skill '$($skillDir.Name)' for profile '$profileId'" + } +} + +$globalSkillsDir = Join-Path $OpenClawDir 'skills' +if (Test-Path -LiteralPath $globalSkillsDir -PathType Container) { + foreach ($skillDir in (Get-ChildItem -LiteralPath $globalSkillsDir -Directory)) { + $target = Join-Path $AgentZeroUsrDir "skills\$($skillDir.Name)" + Copy-DirectoryWithCollisionHandling -SourceDir $skillDir.FullName -TargetDir $target -Label "Global skill '$($skillDir.Name)'" + } +} + +Write-Header 'Migration Complete' +Write-Host '' +Write-Host (" Migrated : {0}" -f $script:Migrated) -ForegroundColor Green +Write-Host (" Skipped : {0}" -f $script:Skipped) -ForegroundColor Cyan +Write-Host (" Warnings : {0}" -f $script:Warnings) -ForegroundColor Yellow +Write-Host '' + +$logLines = @( + '# OpenClaw -> Agent Zero Migration Log', + "Date: $([DateTimeOffset]::Now.ToString('o'))", + "Source: $OpenClawDir", + "Target: $AgentZeroUsrDir", + "Config parser: $ConfigParseMode", + "Migrated: $($script:Migrated)", + "Skipped: $($script:Skipped)", + "Warnings: $($script:Warnings)" +) +Write-Utf8NoBom -Path $MIGRATION_LOG -Content (($logLines -join [Environment]::NewLine) + [Environment]::NewLine) + +report_line '' +report_line '## Manual Follow-Up' +report_line '' +report_line ('- Set Agent Zero workdir or project to one of the generated folders under `' + $MIGRATION_WORKDIR_ROOT + '` if you want the migrated `*.promptinclude.md` files loaded automatically.') +report_line '- Review Telegram routing, DM policy, bindings, and webhook details before enabling the plugin.' +report_line '- OpenClaw OAuth profiles, session history, channel state, and agent isolation are not fully portable.' +report_line '- Review migrated profiles under Settings -> Agents and verify prompts, skills, and model settings.' + +print_info "Migration log: $MIGRATION_LOG" +print_info "Migration report: $script:MigrationReport" +Write-Host '' + +if ($script:Migrated -gt 0) { + Write-Host 'Next steps:' -ForegroundColor White + Write-Host " 1. Review generated profiles under Settings -> Agents" + Write-Host " 2. Point Agent Zero workdir/project to $MIGRATION_WORKDIR_ROOT\ if you want promptinclude behavior" + Write-Host " 3. Review knowledge files under $KNOWLEDGE_DIR and $MEMORY_KNOWLEDGE_DIR" + Write-Host " 4. Review $telegramConfigPath and $telegramNotesPath before enabling Telegram" + Write-Host '' +} + +if ($script:Warnings -gt 0) { + Write-Host 'Review warnings above for items needing manual attention.' -ForegroundColor Yellow + Write-Host '' +} + +Write-Host 'Not migrated automatically:' -ForegroundColor White +Write-Host ' * OpenClaw auth/session/channel isolation semantics' +Write-Host ' * OAuth profiles beyond API-key-style secrets' +Write-Host ' * Session history / transcripts' +Write-Host ' * Channel bindings / agent routing' +Write-Host ' * Heartbeat / cron schedules (use A0 Task Scheduler)'