# OPENCLAW-WRAP: This module will be wrapped around OpenClaw. Preserve public API. """Profile manager for Rex assistant configuration.""" from __future__ import annotations import json from copy import deepcopy from pathlib import Path from typing import Any from rex.logging_utils import get_logger from rex.runtime_paths import profiles_dir as resolve_profiles_dir DEFAULT_PROFILES_DIR = "profiles" logger = get_logger(__name__) def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: result = base.copy() for key, value in overlay.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = _deep_merge(result[key], value) else: result[key] = value return result def _basic_validate(profile: dict[str, Any], required: list[str]) -> None: for key in required: if key not in profile: raise ValueError(f"Profile missing required field: {key}") if not isinstance(profile["profile_version"], int): raise ValueError("profile_version must be an integer") if profile["profile_version"] != 1: raise ValueError("profile_version must be 1") if not isinstance(profile["name"], str): raise ValueError("name must be a string") if not isinstance(profile["description"], str): raise ValueError("description must be a string") if not isinstance(profile["capabilities"], list): raise ValueError("capabilities must be a list") if not isinstance(profile["overrides"], dict): raise ValueError("overrides must be an object") def _validate_profile(profile: dict[str, Any], schema_path: Path) -> None: if not schema_path.exists(): return schema = json.loads(schema_path.read_text(encoding="utf-8")) required = schema.get("required", []) if not isinstance(required, list): required = [] _basic_validate(profile, [str(item) for item in required]) _MINIMAL_DEFAULT_PROFILE: dict[str, Any] = { "profile_version": 1, "name": "default", "description": "Auto-generated default profile", "capabilities": [], "overrides": {}, } def ensure_default_profile(profiles_dir: str | Path | None = None) -> bool: """Ensure ``profiles/default.json`` exists, creating it if necessary. Creation order: 1. Copy ``default.example.json`` if present. 2. Otherwise write a minimal valid profile from scratch. An existing ``default.json`` is never overwritten. Returns: True if the file was created, False if it already existed. """ resolved_dir = resolve_profiles_dir(profiles_dir) dest = resolved_dir / "default.json" if dest.exists(): return False dest.parent.mkdir(parents=True, exist_ok=True) example = resolved_dir / "default.example.json" if example.exists(): dest.write_text(example.read_text(encoding="utf-8"), encoding="utf-8") logger.info("Created default profile from %s", example) else: dest.write_text(json.dumps(_MINIMAL_DEFAULT_PROFILE, indent=2), encoding="utf-8") logger.info("Created minimal default profile at %s", dest) return True def load_profile(name: str, profiles_dir: str | Path | None = None) -> dict[str, Any]: resolved_dir = resolve_profiles_dir(profiles_dir) profile_path = resolved_dir / f"{name}.json" if not profile_path.exists(): raise FileNotFoundError( "Profile file not found: " f"{profile_path}. Create it or set active_profile in config/rex_config.json." ) profile = json.loads(profile_path.read_text(encoding="utf-8")) schema_path = resolved_dir / "profile.schema.json" _validate_profile(profile, schema_path) return profile # type: ignore[no-any-return] def apply_profile(base_config: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: merged = deepcopy(base_config) overrides = profile.get("overrides", {}) if overrides: merged = _deep_merge(merged, overrides) capabilities = profile.get("capabilities", []) if isinstance(capabilities, list): merged["capabilities"] = [item for item in capabilities if item] else: merged["capabilities"] = [] return merged def get_active_profile_name(config: dict[str, Any]) -> str: name = config.get("active_profile", "default") return name or "default" __all__ = [ "DEFAULT_PROFILES_DIR", "ensure_default_profile", "load_profile", "apply_profile", "get_active_profile_name", ]