From 876b1a1c32d4853c7d43322b5b95b07791848708 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 10 Aug 2026 21:41:47 +0200 Subject: [PATCH] fix: deep-merge CLI agent-config with config file values --- cecli/main.py | 30 ++++++++ tests/basic/test_agent_config_merge.py | 94 ++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/basic/test_agent_config_merge.py diff --git a/cecli/main.py b/cecli/main.py index 8e76c2ea411..e2861aab959 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -105,6 +105,31 @@ def convert_yaml_to_json_string(value): return value +def merge_agent_config(cli_agent_config: str, file_agent_config) -> str: + """ + Deep-merge the config-file agent-config into the CLI agent-config so CLI + values override individual keys while keys provided only by the config + files (e.g. skills_paths, skills_init) are preserved instead of being + discarded wholesale when --agent-config is passed on the CLI. + + configargparse discards config-file values for options that are also given + on the command line, so without this merge a CLI --agent-config silently + drops every agent-config key that lives only in .cecli.conf.yml. + """ + try: + from cecli.helpers.config_utils import deep_merge + + file_ac = file_agent_config + if isinstance(file_ac, str): + file_ac = json.loads(file_ac) + cli_ac = json.loads(cli_agent_config) + if isinstance(file_ac, dict) and file_ac and isinstance(cli_ac, dict): + return json.dumps(deep_merge(file_ac, cli_ac, deep_merge_arrays=False)) + except Exception: + pass + return cli_agent_config + + def check_config_files_for_yes(config_files): from cecli.decoding import safe_open @@ -731,6 +756,11 @@ async def main_async( if hasattr(args, "agent_config") and args.agent_config is not None: args.agent_config = convert_yaml_to_json_string(args.agent_config) + # CLI --agent-config should deep-merge with (not replace) the + # agent-config from the merged config files so file-only keys + # (e.g. skills_paths, skills_init) are preserved. + file_agent_config = merged_config.get("agent-config") or merged_config.get("agent_config") + args.agent_config = merge_agent_config(args.agent_config, file_agent_config) if hasattr(args, "tui_config") and args.tui_config is not None: args.tui_config = convert_yaml_to_json_string(args.tui_config) if hasattr(args, "mcp_servers") and args.mcp_servers is not None: diff --git a/tests/basic/test_agent_config_merge.py b/tests/basic/test_agent_config_merge.py new file mode 100644 index 00000000000..8b9e3d9b58d --- /dev/null +++ b/tests/basic/test_agent_config_merge.py @@ -0,0 +1,94 @@ +import json +import os +import tempfile + +import yaml + +from cecli.args import get_parser +from cecli.helpers import config_utils +from cecli.main import convert_yaml_to_json_string, merge_agent_config + + +def test_cli_agent_config_merges_with_config_file(): + """CLI --agent-config must deep-merge with the config-file agent-config + instead of replacing it wholesale (regression: file-only keys dropped).""" + file_ac = { + "command_timeout": 0, + "skip_cli_confirmations": True, + "tools_paths": ["/tmp/mytools"], + "skills_paths": ["/tmp/skills"], + } + cli = json.dumps({"command_timeout": 120}) + merged = json.loads(merge_agent_config(cli, file_ac)) + assert merged["command_timeout"] == 120 # CLI wins per-key + assert merged["skip_cli_confirmations"] is True # file-only key preserved + assert merged["tools_paths"] == ["/tmp/mytools"] # file-only key preserved + assert merged["skills_paths"] == ["/tmp/skills"] # file-only key preserved + + +def test_file_agent_config_given_as_json_string(): + """Merged config from read_and_merge_all_configs holds agent-config as a + JSON string (YAML block scalar) - the helper must handle that form.""" + file_ac = '{"command_timeout": 0, "skills_paths": ["/tmp/skills"]}' + merged = json.loads(merge_agent_config('{"skip_cli_confirmations": true}', file_ac)) + assert merged["skip_cli_confirmations"] is True + assert merged["command_timeout"] == 0 + assert merged["skills_paths"] == ["/tmp/skills"] + + +def test_no_file_agent_config_returns_cli_unchanged(): + """Without a config-file agent-config the merge must be a no-op.""" + cli = '{"command_timeout": 120}' + assert merge_agent_config(cli, None) == cli + assert merge_agent_config(cli, {}) == cli + assert merge_agent_config(cli, "not json") == cli + + +def test_nested_dict_keys_deep_merged(): + """Nested dicts under agent-config are merged recursively, CLI wins.""" + file_ac = {"nested": {"a": 1, "b": 2}} + merged = json.loads(merge_agent_config('{"nested": {"b": 9, "c": 3}}', file_ac)) + assert merged["nested"] == {"a": 1, "b": 9, "c": 3} + + +def test_cli_array_replaces_file_array(): + """Arrays are not deep-merged: CLI list values win wholesale.""" + file_ac = {"skills_paths": ["/tmp/file-skills"]} + merged = json.loads(merge_agent_config('{"skills_paths": ["/tmp/cli-skills"]}', file_ac)) + assert merged["skills_paths"] == ["/tmp/cli-skills"] + + +def test_main_async_pipeline_preserves_file_keys(tmp_path): + """Replicates main_async: merge config files -> temp yaml -> parser -> + CLI parse -> temp file deleted -> convert + merge against merged_config. + + Guards against regressing to the broken baseline (re-parsing argv after + the temp config file was already unlinked yields no config-file values). + """ + conf = tmp_path / ".cecli.conf.yml" + conf.write_text( + "agent-config: |\n" + ' {"skills_paths": ["./.cecli/skills"], "skills_init": ["android-cli"]}\n' + ) + paths = [str(conf)] + merged_config = config_utils.read_and_merge_all_configs(paths, [], paths) + + fd, tmp = tempfile.mkstemp(suffix=".yml", prefix="cecli_merged_") + os.close(fd) + with open(tmp, "w") as f: + yaml.dump(merged_config, f) + try: + parser = get_parser([tmp], None) + argv = ['--agent-config={"command_timeout": 0}'] + args, _ = parser.parse_known_args(argv) + finally: + os.unlink(tmp) # main_async deletes the temp file before the merge point + + args.agent_config = convert_yaml_to_json_string(args.agent_config) + file_ac = merged_config.get("agent-config") + args.agent_config = merge_agent_config(args.agent_config, file_ac) + + merged = json.loads(args.agent_config) + assert merged["command_timeout"] == 0 + assert merged["skills_paths"] == ["./.cecli/skills"] + assert merged["skills_init"] == ["android-cli"]