Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions tests/basic/test_agent_config_merge.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading