diff --git a/config.cron.yaml b/config.cron.yaml new file mode 100644 index 00000000..074525ef --- /dev/null +++ b/config.cron.yaml @@ -0,0 +1,19 @@ +# Cron/headless environment overlay +# For scheduled jobs and autonomous agents +model: + default: qwen3:30b + provider: custom +agent: + max_turns: 100 + verbose: false +display: + compact: true + show_reasoning: false + streaming: false + resume_display: minimal +terminal: + timeout: 300 + persistent_shell: true +memory: + memory_enabled: false + user_profile_enabled: false diff --git a/config.dev.yaml b/config.dev.yaml new file mode 100644 index 00000000..b6a9421a --- /dev/null +++ b/config.dev.yaml @@ -0,0 +1,14 @@ +# Dev environment overlay +# Merges with config.yaml — these keys override the base +model: + default: qwen3:30b + provider: custom +agent: + max_turns: 50 + verbose: true +display: + show_reasoning: true + streaming: true + show_cost: true +terminal: + timeout: 300 diff --git a/config.gateway.yaml b/config.gateway.yaml new file mode 100644 index 00000000..99ecaa85 --- /dev/null +++ b/config.gateway.yaml @@ -0,0 +1,17 @@ +# Gateway environment overlay +# For messaging platform gateway (Telegram, Discord, etc.) +model: + default: qwen3:30b + provider: custom +agent: + max_turns: 30 + verbose: false +display: + compact: true + show_reasoning: false + streaming: false +terminal: + timeout: 120 +memory: + memory_enabled: true + user_profile_enabled: true diff --git a/config.prod.yaml b/config.prod.yaml new file mode 100644 index 00000000..badac220 --- /dev/null +++ b/config.prod.yaml @@ -0,0 +1,17 @@ +# Prod environment overlay +# Merges with config.yaml — these keys override the base +model: + default: claude-opus-4-6 + provider: anthropic +agent: + max_turns: 90 + verbose: false +display: + compact: true + show_reasoning: false + streaming: false + show_cost: false +privacy: + redact_pii: true +security: + redact_secrets: true diff --git a/config_overlay.py b/config_overlay.py new file mode 100644 index 00000000..cb3510e2 --- /dev/null +++ b/config_overlay.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +config_overlay.py — Environment-specific config overlays. + +Merges a base config YAML with an environment-specific overlay. +Overlay keys override base keys (deep merge for dicts, replace for lists). + +Usage: + from config_overlay import load_config + config = load_config('config.yaml', env='prod') + # Loads config.yaml + config.prod.yaml, merges, returns dict + +Environments: dev, prod, cron, gateway (or custom) +""" +import os +import copy +import yaml +from pathlib import Path + + +def deep_merge(base: dict, overlay: dict) -> dict: + """Deep merge overlay into base. Overlay wins on conflicts. + + - Dicts are merged recursively + - Lists are replaced (not extended) + - Scalars are replaced + - None in overlay removes the key from base + """ + result = copy.deepcopy(base) + for key, value in overlay.items(): + if value is None: + result.pop(key, None) + elif key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = copy.deepcopy(value) + return result + + +def find_config(base_path: str, env: str = None) -> tuple: + """Find base and overlay config paths. + + Returns: (base_path, overlay_path_or_None) + """ + base = Path(base_path) + if not base.exists(): + raise FileNotFoundError(f"Base config not found: {base_path}") + + overlay = None + if env: + # Try: config.{env}.yaml, config/{env}.yaml, {env}.overlay.yaml + candidates = [ + base.parent / f"{base.stem}.{env}{base.suffix}", + base.parent / env / f"{base.name}", + base.parent / f"{env}.overlay{base.suffix}", + ] + for c in candidates: + if c.exists(): + overlay = c + break + + return base, overlay + + +def load_config(base_path: str, env: str = None) -> dict: + """Load base config and merge with environment overlay. + + Args: + base_path: Path to base config YAML + env: Environment name (dev, prod, cron, gateway, or custom) + + Returns: + Merged config dict + """ + base_file, overlay_file = find_config(base_path, env) + + with open(base_file) as f: + base_config = yaml.safe_load(f) or {} + + if overlay_file and overlay_file.exists(): + with open(overlay_file) as f: + overlay_config = yaml.safe_load(f) or {} + return deep_merge(base_config, overlay_config) + + return base_config + + +def detect_env() -> str: + """Auto-detect environment from env vars. + + Checks: TIMMY_ENV, HERMES_ENV, ENVIRONMENT, NODE_ENV + """ + for var in ('TIMMY_ENV', 'HERMES_ENV', 'ENVIRONMENT', 'NODE_ENV'): + val = os.environ.get(var, '').strip().lower() + if val: + return val + return None + + +def load_config_auto(base_path: str) -> dict: + """Load config with auto-detected environment.""" + env = detect_env() + return load_config(base_path, env=env) + + +def list_overlays(base_path: str) -> list: + """List available overlay files for a base config.""" + base = Path(base_path) + pattern = f"{base.stem}.*{base.suffix}" + overlays = [] + for f in sorted(base.parent.glob(pattern)): + if f == base: + continue + env_name = f.stem.replace(base.stem + '.', '') + overlays.append({ + 'env': env_name, + 'path': str(f), + }) + return overlays + + +if __name__ == '__main__': + import sys + import json + + base = sys.argv[1] if len(sys.argv) > 1 else 'config.yaml' + env = sys.argv[2] if len(sys.argv) > 2 else None + + config = load_config(base, env=env) + print(json.dumps(config, indent=2, default=str)) diff --git a/tests/test_config_overlay.py b/tests/test_config_overlay.py new file mode 100644 index 00000000..dc1a92b6 --- /dev/null +++ b/tests/test_config_overlay.py @@ -0,0 +1,136 @@ +"""Tests for config overlay system.""" +import os +import json +import tempfile +import yaml +import pytest + + +def test_deep_merge_dicts(): + """Deep merge should recursively merge dicts.""" + from config_overlay import deep_merge + base = {"a": {"b": 1, "c": 2}, "d": 3} + overlay = {"a": {"b": 10, "e": 5}} + result = deep_merge(base, overlay) + assert result == {"a": {"b": 10, "c": 2, "e": 5}, "d": 3} + + +def test_deep_merge_lists_replaced(): + """Lists should be replaced, not extended.""" + from config_overlay import deep_merge + base = {"items": [1, 2, 3]} + overlay = {"items": [4, 5]} + result = deep_merge(base, overlay) + assert result == {"items": [4, 5]} + + +def test_deep_merge_scalars_overridden(): + """Scalar values should be overridden by overlay.""" + from config_overlay import deep_merge + base = {"name": "base", "count": 10} + overlay = {"name": "override", "count": 20} + result = deep_merge(base, overlay) + assert result == {"name": "override", "count": 20} + + +def test_deep_merge_none_removes_key(): + """None in overlay should remove the key from base.""" + from config_overlay import deep_merge + base = {"a": 1, "b": 2, "c": 3} + overlay = {"b": None} + result = deep_merge(base, overlay) + assert result == {"a": 1, "c": 3} + + +def test_deep_merge_empty_overlay(): + """Empty overlay should return base unchanged.""" + from config_overlay import deep_merge + base = {"a": 1, "b": {"c": 2}} + result = deep_merge(base, {}) + assert result == base + + +def test_load_config_no_env(tmp_path): + """Load config without overlay should return base.""" + from config_overlay import load_config + base = {"model": "test", "agent": {"max_turns": 10}} + path = tmp_path / "config.yaml" + path.write_text(yaml.dump(base)) + result = load_config(str(path)) + assert result == base + + +def test_load_config_with_overlay(tmp_path): + """Load config with overlay should merge.""" + from config_overlay import load_config + base = {"model": "base", "agent": {"max_turns": 10, "verbose": False}} + overlay = {"model": "override", "agent": {"verbose": True}} + (tmp_path / "config.yaml").write_text(yaml.dump(base)) + (tmp_path / "config.dev.yaml").write_text(yaml.dump(overlay)) + result = load_config(str(tmp_path / "config.yaml"), env="dev") + assert result["model"] == "override" + assert result["agent"]["max_turns"] == 10 + assert result["agent"]["verbose"] is True + + +def test_load_config_missing_overlay(tmp_path): + """Missing overlay should silently return base.""" + from config_overlay import load_config + base = {"model": "base"} + (tmp_path / "config.yaml").write_text(yaml.dump(base)) + result = load_config(str(tmp_path / "config.yaml"), env="nonexistent") + assert result == base + + +def test_find_config(tmp_path): + """find_config should locate base and overlay.""" + from config_overlay import find_config + base = tmp_path / "config.yaml" + base.write_text("a: 1") + overlay = tmp_path / "config.prod.yaml" + overlay.write_text("a: 2") + b, o = find_config(str(base), "prod") + assert b == base + assert o == overlay + + +def test_list_overlays(tmp_path): + """list_overlays should find all overlay files.""" + from config_overlay import list_overlays + (tmp_path / "config.yaml").write_text("a: 1") + (tmp_path / "config.dev.yaml").write_text("a: 2") + (tmp_path / "config.prod.yaml").write_text("a: 3") + overlays = list_overlays(str(tmp_path / "config.yaml")) + envs = [o['env'] for o in overlays] + assert 'dev' in envs + assert 'prod' in envs + + +def test_detect_env_from_var(tmp_path, monkeypatch): + """detect_env should check TIMMY_ENV first.""" + from config_overlay import detect_env + monkeypatch.setenv("TIMMY_ENV", "prod") + assert detect_env() == "prod" + + +def test_detect_env_fallback(tmp_path, monkeypatch): + """detect_env should fall back through vars.""" + from config_overlay import detect_env + monkeypatch.delenv("TIMMY_ENV", raising=False) + monkeypatch.delenv("HERMES_ENV", raising=False) + monkeypatch.setenv("ENVIRONMENT", "cron") + assert detect_env() == "cron" + + +def test_real_config_overlay(): + """Test against actual config files in the repo.""" + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + from config_overlay import load_config + + base_path = os.path.join(os.path.dirname(__file__), '..', 'config.yaml') + if os.path.exists(base_path): + config = load_config(base_path, env='dev') + assert config['model']['default'] == 'qwen3:30b' # dev overrides + assert config['agent']['max_turns'] == 50 # dev overrides + assert 'terminal' in config # base keys preserved