From 7b3fcaf4665e245bf9d22d01e44cc432fb2fcb19 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 08:17:29 +0000 Subject: [PATCH] feat(config): implement three-scope config resolution with local config file support Implement three-scope configuration resolution (local > project > global) with deep merge. Add ConfigScope enum, project-root discovery, and config.local.toml file support. Changes: - Add LOCAL to ConfigLevel enum and new ConfigScope enum (GLOBAL, PROJECT, LOCAL) - Implement discover_project_root() walking up from CWD for cleveragents.toml/.cleveragents - Implement config.local.toml file loading in ConfigService - Implement three-scope deep merge via _deep_merge() helper - Add read_project_config(), read_local_config(), read_merged_config() methods - Add write_scoped_config() for writing to any scope file - Update set_value() to accept optional scope parameter - Update resolve() with six-level precedence chain (CLI > env > local > project > global > default) - Update CLI config set with --scope flag (global/project/local) - Add config.local.toml to .gitignore and DEFAULT_IGNORE_PATTERNS - Add Behave BDD scenarios for three-scope resolution, deep merge, and project-root discovery ISSUES CLOSED: #937 --- .gitignore | 1 + .../config_cli_safety_net_coverage.feature | 6 +- features/config_three_scope.feature | 119 ++++++ features/consolidated_config.feature | 11 +- .../steps/config_service_coverage_steps.py | 12 +- features/steps/config_three_scope_steps.py | 353 ++++++++++++++++++ robot/config_resolution.robot | 2 +- robot/helper_config_resolution.py | 13 +- .../application/services/config_service.py | 254 ++++++++++++- .../application/services/context_service.py | 1 + src/cleveragents/cli/commands/config.py | 47 ++- 11 files changed, 775 insertions(+), 44 deletions(-) create mode 100644 features/config_three_scope.feature create mode 100644 features/steps/config_three_scope_steps.py diff --git a/.gitignore b/.gitignore index 37395c432..28820f61a 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ claude-flow.ps1 hive-mind-prompt-*.txt .cleveragents/ +config.local.toml .pabotsuitenames PIPE diff --git a/features/config_cli_safety_net_coverage.feature b/features/config_cli_safety_net_coverage.feature index 8356a7246..ce8d878e2 100644 --- a/features/config_cli_safety_net_coverage.feature +++ b/features/config_cli_safety_net_coverage.feature @@ -138,11 +138,11 @@ Feature: Config CLI safety-net coverage # _resolution_chain (L177-209) # ===================================================================== - Scenario: safety-net _resolution_chain returns five-entry list + Scenario: safety-net _resolution_chain returns six-entry list Given a safety-net isolated temp config directory When the safety-net chain builder builds chain for key "core.log.level" - Then the safety-net chain should have exactly 5 entries - And the safety-net chain sources should be "cli_flag, env_var, project, global, default" + Then the safety-net chain should have exactly 6 entries + And the safety-net chain sources should be "cli_flag, env_var, local, project, global, default" # ===================================================================== # config_set - type coercion paths (L241-248) diff --git a/features/config_three_scope.feature b/features/config_three_scope.feature new file mode 100644 index 000000000..087c31ec2 --- /dev/null +++ b/features/config_three_scope.feature @@ -0,0 +1,119 @@ +Feature: Three-scope configuration resolution + As a developer working on a CleverAgents project + I want configuration to resolve across global, project, and local scopes + So that I can have developer-specific overrides without affecting shared config + + Background: + Given a clean three-scope config environment + + # ── Scope precedence ────────────────────────────────────────────────── + + Scenario: Local scope overrides project scope + Given a project config value "core.automation-profile" = "supervised" + And a local config value "core.automation-profile" = "manual" + When I resolve three-scope key "core.automation-profile" + Then the three-scope resolved value should be "manual" + And the three-scope resolved source should be "local" + + Scenario: Project scope overrides global scope + Given a global config value for three-scope "core.automation-profile" = "full-auto" + And a project config value "core.automation-profile" = "supervised" + When I resolve three-scope key "core.automation-profile" + Then the three-scope resolved value should be "supervised" + And the three-scope resolved source should be "project" + + Scenario: Local scope overrides both project and global scopes + Given a global config value for three-scope "core.automation-profile" = "full-auto" + And a project config value "core.automation-profile" = "supervised" + And a local config value "core.automation-profile" = "manual" + When I resolve three-scope key "core.automation-profile" + Then the three-scope resolved value should be "manual" + And the three-scope resolved source should be "local" + + Scenario: Global scope used when no project or local override exists + Given a global config value for three-scope "core.automation-profile" = "supervised" + When I resolve three-scope key "core.automation-profile" + Then the three-scope resolved value should be "supervised" + And the three-scope resolved source should be "global" + + Scenario: Default used when no scope provides a value + When I resolve three-scope key "core.automation-profile" + Then the three-scope resolved source should be "default" + + # ── Deep merge ──────────────────────────────────────────────────────── + + Scenario: Deep merge combines nested keys from all scopes + Given a global config with nested data "section_a.key1" = "global_val1" + And a project config with nested data "section_a.key2" = "project_val2" + And a local config with nested data "section_a.key3" = "local_val3" + When I read the three-scope merged config + Then the merged config "section_a" should contain key "key1" with value "global_val1" + And the merged config "section_a" should contain key "key2" with value "project_val2" + And the merged config "section_a" should contain key "key3" with value "local_val3" + + Scenario: Deep merge local overrides project on conflict + Given a project config with nested data "section_a.key1" = "project_val" + And a local config with nested data "section_a.key1" = "local_val" + When I read the three-scope merged config + Then the merged config "section_a" should contain key "key1" with value "local_val" + + # ── Project root discovery ──────────────────────────────────────────── + + Scenario: Project root discovered via cleveragents.toml marker + Given a directory tree with "cleveragents.toml" marker at the root + When I discover the project root from a subdirectory + Then the discovered project root should match the marker directory + + Scenario: Project root discovered via .cleveragents directory marker + Given a directory tree with ".cleveragents" directory marker at the root + When I discover the project root from a subdirectory + Then the discovered project root should match the marker directory + + Scenario: No project root returns None when no markers found + Given a directory tree with no project markers + When I discover the project root + Then the discovered project root should be None + + # ── config.local.toml file loading ──────────────────────────────────── + + Scenario: Local config file is loaded when present + Given a local config file with "plan.concurrency" = "16" + When I resolve three-scope key "plan.concurrency" + Then the three-scope resolved value should be "16" + And the three-scope resolved source should be "local" + + Scenario: Missing local config file is handled gracefully + When I read the local config + Then the local config should be an empty dict + + # ── Scoped write ────────────────────────────────────────────────────── + + Scenario: Set value with local scope writes to config.local.toml + When I set three-scope value "core.automation-profile" to "manual" with scope "local" + Then the local config file should contain "core.automation-profile" = "manual" + + Scenario: Set value with project scope writes to project config.toml + When I set three-scope value "core.automation-profile" to "supervised" with scope "project" + Then the project config file should contain "core.automation-profile" = "supervised" + + Scenario: Set value with global scope writes to global config.toml + When I set three-scope value "core.automation-profile" to "full-auto" with scope "global" + Then the global config file should contain "core.automation-profile" = "full-auto" + + # ── ConfigScope enum ────────────────────────────────────────────────── + + Scenario: ConfigScope enum has all three values + Then ConfigScope should have value "global" + And ConfigScope should have value "project" + And ConfigScope should have value "local" + + # ── ConfigLevel enum includes LOCAL ─────────────────────────────────── + + Scenario: ConfigLevel enum includes LOCAL between ENV_VAR and PROJECT + Then ConfigLevel should have value "local" + And ConfigLevel "local" should exist between "env_var" and "project" + + # ── .gitignore template includes config.local.toml ──────────────────── + + Scenario: Default ignore patterns include config.local.toml + Then the default ignore patterns should include "config.local.toml" diff --git a/features/consolidated_config.feature b/features/consolidated_config.feature index 92ed084de..4ec8451fe 100644 --- a/features/consolidated_config.feature +++ b/features/consolidated_config.feature @@ -172,9 +172,10 @@ Feature: Consolidated Config Scenario: Verbose mode returns full resolution chain Given a clean config service test environment When I resolve config key "core.log.level" with verbose mode - Then the resolution chain should have 5 entries + Then the resolution chain should have 6 entries And the chain should include source "cli_flag" And the chain should include source "env_var" + And the chain should include source "local" And the chain should include source "project" And the chain should include source "global" And the chain should include source "default" @@ -293,8 +294,8 @@ Feature: Consolidated Config # Feature: ConfigService full coverage # ============================================================ - Scenario: ConfigLevel enum has five precedence levels - Then ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT + Scenario: ConfigLevel enum has six precedence levels + Then ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT # ---------- ConfigEntry and ResolvedValue dataclasses ---------- @@ -495,7 +496,7 @@ Feature: Consolidated Config When I call validate_type with unknown key "fake.unknown" and value "x" Then the raised ValueError should mention "Cannot validate type for unknown key" - # ---------- resolve: five-level chain ---------- + # ---------- resolve: six-level chain ---------- Scenario: resolve returns default value when no overrides exist @@ -546,7 +547,7 @@ Feature: Consolidated Config Given a temporary directory for config service And a ConfigService with empty config for resolve tests When I resolve key "core.log.level" with verbose True and no overrides - Then the resolved chain should have 5 entries covering all levels + Then the resolved chain should have 6 entries covering all levels Scenario: resolve verbose chain includes env_name for ENV_VAR level diff --git a/features/steps/config_service_coverage_steps.py b/features/steps/config_service_coverage_steps.py index 43cd99082..262bda4ab 100644 --- a/features/steps/config_service_coverage_steps.py +++ b/features/steps/config_service_coverage_steps.py @@ -25,14 +25,15 @@ from cleveragents.application.services.config_service import ( # --------------------------------------------------------------------------- -@then("ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT") +@then("ConfigLevel should have values CLI_FLAG ENV_VAR LOCAL PROJECT GLOBAL DEFAULT") def step_config_level_enum(context: Any) -> None: assert ConfigLevel.CLI_FLAG.value == "cli_flag" assert ConfigLevel.ENV_VAR.value == "env_var" + assert ConfigLevel.LOCAL.value == "local" assert ConfigLevel.PROJECT.value == "project" assert ConfigLevel.GLOBAL.value == "global" assert ConfigLevel.DEFAULT.value == "default" - assert len(ConfigLevel) == 5 + assert len(ConfigLevel) == 6 # --------------------------------------------------------------------------- @@ -570,7 +571,7 @@ def step_verify_validate_type_unknown_key_error(context: Any) -> None: # --------------------------------------------------------------------------- -# resolve - five-level chain +# resolve - six-level chain # --------------------------------------------------------------------------- @@ -679,14 +680,15 @@ def step_resolve_verbose(context: Any) -> None: context.result = context.svc.resolve("core.log.level", verbose=True) -@then("the resolved chain should have 5 entries covering all levels") +@then("the resolved chain should have 6 entries covering all levels") def step_verify_verbose_chain_length(context: Any) -> None: chain = context.result.chain - assert len(chain) == 5, f"Expected 5 chain entries, got {len(chain)}" + assert len(chain) == 6, f"Expected 6 chain entries, got {len(chain)}" sources = [entry["source"] for entry in chain] assert sources == [ ConfigLevel.CLI_FLAG.value, ConfigLevel.ENV_VAR.value, + ConfigLevel.LOCAL.value, ConfigLevel.PROJECT.value, ConfigLevel.GLOBAL.value, ConfigLevel.DEFAULT.value, diff --git a/features/steps/config_three_scope_steps.py b/features/steps/config_three_scope_steps.py new file mode 100644 index 000000000..a37422844 --- /dev/null +++ b/features/steps/config_three_scope_steps.py @@ -0,0 +1,353 @@ +"""Step definitions for config_three_scope.feature — three-scope resolution.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import tomllib +from pathlib import Path +from typing import Any + +import tomlkit +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services import config_service as _config_service_mod +from cleveragents.application.services.config_service import ( + ConfigLevel, + ConfigScope, + ConfigService, + discover_project_root, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _svc(context: Context) -> ConfigService: + return context._three_scope_service + + +def _write_toml(path: Path, data: dict[str, Any]) -> None: + """Write *data* as TOML to *path*, creating parent dirs.""" + path.parent.mkdir(parents=True, exist_ok=True) + doc = tomlkit.document() + for key, value in data.items(): + doc[key] = value + with open(path, "w") as fh: + tomlkit.dump(doc, fh) + + +def _read_toml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with open(path, "rb") as fh: + return tomllib.load(fh) + + +def _cleanup(context: Context) -> None: + tmpdir = getattr(context, "_three_scope_tmpdir", None) + if tmpdir and os.path.isdir(tmpdir): + shutil.rmtree(tmpdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a clean three-scope config environment") +def step_clean_three_scope_env(context: Context) -> None: + tmpdir = tempfile.mkdtemp() + context._three_scope_tmpdir = tmpdir + base = Path(tmpdir) + + # Global config dir + global_dir = base / "global_config" + global_dir.mkdir() + + # Project root (with marker) + project_root = base / "project" + project_root.mkdir() + (project_root / "cleveragents.toml").touch() + + context._three_scope_global_dir = global_dir + context._three_scope_project_root = project_root + + context._three_scope_service = ConfigService( + config_dir=global_dir, + config_path=global_dir / "config.toml", + project_root=project_root, + ) + + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(lambda: _cleanup(context)) + + +# --------------------------------------------------------------------------- +# Given — scope values +# --------------------------------------------------------------------------- + + +@given('a global config value for three-scope "{key}" = "{value}"') +def step_global_value(context: Context, key: str, value: str) -> None: + svc = _svc(context) + data = svc.read_config() + data[key] = value + svc.write_config(data) + + +@given('a project config value "{key}" = "{value}"') +def step_project_value(context: Context, key: str, value: str) -> None: + path = context._three_scope_project_root / "config.toml" + data = _read_toml(path) + data[key] = value + _write_toml(path, data) + + +@given('a local config value "{key}" = "{value}"') +def step_local_value(context: Context, key: str, value: str) -> None: + path = context._three_scope_project_root / "config.local.toml" + data = _read_toml(path) + data[key] = value + _write_toml(path, data) + + +# Given — nested data for deep merge +@given('a global config with nested data "{dotted}" = "{value}"') +def step_global_nested(context: Context, dotted: str, value: str) -> None: + svc = _svc(context) + data = svc.read_config() + section, key = dotted.split(".", 1) + data.setdefault(section, {})[key] = value + svc.write_config(data) + + +@given('a project config with nested data "{dotted}" = "{value}"') +def step_project_nested(context: Context, dotted: str, value: str) -> None: + path = context._three_scope_project_root / "config.toml" + data = _read_toml(path) + section, key = dotted.split(".", 1) + data.setdefault(section, {})[key] = value + _write_toml(path, data) + + +@given('a local config with nested data "{dotted}" = "{value}"') +def step_local_nested(context: Context, dotted: str, value: str) -> None: + path = context._three_scope_project_root / "config.local.toml" + data = _read_toml(path) + section, key = dotted.split(".", 1) + data.setdefault(section, {})[key] = value + _write_toml(path, data) + + +# Given — project root discovery +@given('a directory tree with "{marker}" marker at the root') +def step_dir_tree_with_marker_file(context: Context, marker: str) -> None: + tmpdir = tempfile.mkdtemp() + context._discovery_tmpdir = tmpdir + root = Path(tmpdir) / "project_root" + root.mkdir() + sub = root / "a" / "b" + sub.mkdir(parents=True) + target = root / marker + if marker.startswith("."): + target.mkdir(parents=True, exist_ok=True) + else: + target.touch() + context._discovery_root = root + context._discovery_sub = sub + + +@given('a directory tree with ".cleveragents" directory marker at the root') +def step_dir_tree_with_dir_marker(context: Context) -> None: + tmpdir = tempfile.mkdtemp() + context._discovery_tmpdir = tmpdir + root = Path(tmpdir) / "project_root" + root.mkdir() + sub = root / "a" / "b" + sub.mkdir(parents=True) + (root / ".cleveragents").mkdir() + context._discovery_root = root + context._discovery_sub = sub + + +@given("a directory tree with no project markers") +def step_dir_tree_no_markers(context: Context) -> None: + tmpdir = tempfile.mkdtemp() + context._discovery_tmpdir = tmpdir + context._discovery_sub = Path(tmpdir) + # Temporarily replace markers with a name that cannot exist in the + # ancestor chain so the walk-up never matches a real directory such + # as /tmp/.cleveragents left by a prior CleverAgents installation. + context._original_markers = _config_service_mod._PROJECT_ROOT_MARKERS + _config_service_mod._PROJECT_ROOT_MARKERS = ("__cleveragents_nonexistent_marker__",) + context._cleanup_handlers.append( + lambda: setattr( + _config_service_mod, + "_PROJECT_ROOT_MARKERS", + context._original_markers, + ) + ) + + +# Given — local config file +@given('a local config file with "{key}" = "{value}"') +def step_local_file_with_key(context: Context, key: str, value: str) -> None: + path = context._three_scope_project_root / "config.local.toml" + data = _read_toml(path) + data[key] = value + _write_toml(path, data) + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when('I resolve three-scope key "{key}"') +def step_resolve_three_scope(context: Context, key: str) -> None: + svc = _svc(context) + context._three_scope_resolved = svc.resolve(key) + + +@when("I read the three-scope merged config") +def step_read_merged(context: Context) -> None: + svc = _svc(context) + context._three_scope_merged = svc.read_merged_config() + + +@when("I discover the project root from a subdirectory") +def step_discover_from_subdir(context: Context) -> None: + context._discovered_root = discover_project_root(context._discovery_sub) + + +@when("I discover the project root") +def step_discover_project_root(context: Context) -> None: + context._discovered_root = discover_project_root(context._discovery_sub) + + +@when("I read the local config") +def step_read_local(context: Context) -> None: + svc = _svc(context) + context._local_config = svc.read_local_config() + + +@when('I set three-scope value "{key}" to "{value}" with scope "{scope}"') +def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None: + svc = _svc(context) + config_scope = ConfigScope(scope) + svc.set_value(key, value, scope=config_scope) + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then('the three-scope resolved value should be "{value}"') +def step_check_value(context: Context, value: str) -> None: + actual = str(context._three_scope_resolved.value) + assert actual == value, f"Expected '{value}', got '{actual}'" + + +@then('the three-scope resolved source should be "{source}"') +def step_check_source(context: Context, source: str) -> None: + actual = context._three_scope_resolved.source.value + assert actual == source, f"Expected source '{source}', got '{actual}'" + + +@then('the merged config "{section}" should contain key "{key}" with value "{value}"') +def step_merged_section_key( + context: Context, section: str, key: str, value: str +) -> None: + merged = context._three_scope_merged + assert section in merged, f"Section '{section}' not in merged config: {merged}" + sec = merged[section] + assert key in sec, f"Key '{key}' not in section '{section}': {sec}" + assert str(sec[key]) == value, ( + f"Expected '{value}' for {section}.{key}, got '{sec[key]}'" + ) + + +@then("the discovered project root should match the marker directory") +def step_root_matches(context: Context) -> None: + expected = context._discovery_root.resolve() + actual = context._discovered_root + assert actual is not None, "Expected a project root, got None" + assert actual.resolve() == expected, f"Expected {expected}, got {actual.resolve()}" + + +@then("the discovered project root should be None") +def step_root_none(context: Context) -> None: + assert context._discovered_root is None, ( + f"Expected None, got {context._discovered_root}" + ) + + +@then("the local config should be an empty dict") +def step_local_empty(context: Context) -> None: + assert context._local_config == {}, ( + f"Expected empty dict, got {context._local_config}" + ) + + +@then('the local config file should contain "{key}" = "{value}"') +def step_local_file_contains(context: Context, key: str, value: str) -> None: + path = context._three_scope_project_root / "config.local.toml" + data = _read_toml(path) + assert key in data, f"Key '{key}' not in local config: {data}" + assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" + + +@then('the project config file should contain "{key}" = "{value}"') +def step_project_file_contains(context: Context, key: str, value: str) -> None: + path = context._three_scope_project_root / "config.toml" + data = _read_toml(path) + assert key in data, f"Key '{key}' not in project config: {data}" + assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" + + +@then('the global config file should contain "{key}" = "{value}"') +def step_global_file_contains(context: Context, key: str, value: str) -> None: + svc = _svc(context) + data = svc.read_config() + assert key in data, f"Key '{key}' not in global config: {data}" + assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" + + +@then('ConfigScope should have value "{value}"') +def step_scope_enum_value(context: Context, value: str) -> None: + scope = ConfigScope(value) + assert scope.value == value, f"Expected ConfigScope.{value}, got {scope}" + + +@then('ConfigLevel should have value "{value}"') +def step_level_enum_value(context: Context, value: str) -> None: + level = ConfigLevel(value) + assert level.value == value, f"Expected ConfigLevel.{value}, got {level}" + + +@then('ConfigLevel "{value}" should exist between "{before}" and "{after}"') +def step_level_ordering(context: Context, value: str, before: str, after: str) -> None: + members = [m.value for m in ConfigLevel] + idx_before = members.index(before) + idx_value = members.index(value) + idx_after = members.index(after) + assert idx_before < idx_value < idx_after, ( + f"Expected {before} < {value} < {after} in {members}" + ) + + +@then('the default ignore patterns should include "{pattern}"') +def step_ignore_patterns(context: Context, pattern: str) -> None: + from cleveragents.application.services.context_service import ( + DEFAULT_IGNORE_PATTERNS, + ) + + assert pattern in DEFAULT_IGNORE_PATTERNS, ( + f"'{pattern}' not in DEFAULT_IGNORE_PATTERNS: {DEFAULT_IGNORE_PATTERNS}" + ) diff --git a/robot/config_resolution.robot b/robot/config_resolution.robot index 4b81f3f6b..74d6afc4a 100644 --- a/robot/config_resolution.robot +++ b/robot/config_resolution.robot @@ -57,7 +57,7 @@ Config Resolution Project Scope Should Contain ${result.stdout} config-resolution-project-ok Config Resolution Verbose Chain - [Documentation] Verify that verbose=True returns all 5 chain entries + [Documentation] Verify that verbose=True returns all 6 chain entries ${result}= Run Process ${PYTHON} ${HELPER} resolve-verbose-chain cwd=${WORKSPACE} Log ${result.stdout} Log ${result.stderr} diff --git a/robot/helper_config_resolution.py b/robot/helper_config_resolution.py index ff267d531..6effd4b9c 100644 --- a/robot/helper_config_resolution.py +++ b/robot/helper_config_resolution.py @@ -31,7 +31,11 @@ from cleveragents.application.services.config_service import ( # noqa: E402 def _make_service() -> tuple[ConfigService, Path]: """Create a ConfigService backed by an isolated temp directory.""" tmpdir = Path(tempfile.mkdtemp()) - svc = ConfigService(config_dir=tmpdir, config_path=tmpdir / "config.toml") + svc = ConfigService( + config_dir=tmpdir, + config_path=tmpdir / "config.toml", + project_root=None, + ) return svc, tmpdir @@ -178,16 +182,17 @@ def resolve_project() -> None: def resolve_verbose_chain() -> None: - """Verify verbose=True populates all 5 chain entries.""" + """Verify verbose=True populates all 6 chain entries.""" svc, tmpdir = _make_service() try: result = svc.resolve("core.log.level", verbose=True) chain_len = len(result.chain) - if chain_len == 5: + if chain_len == 6: # Verify the sources in order expected_sources = [ ConfigLevel.CLI_FLAG.value, ConfigLevel.ENV_VAR.value, + ConfigLevel.LOCAL.value, ConfigLevel.PROJECT.value, ConfigLevel.GLOBAL.value, ConfigLevel.DEFAULT.value, @@ -203,7 +208,7 @@ def resolve_verbose_chain() -> None: sys.exit(1) else: print( - f"FAIL: expected 5 chain entries, got {chain_len}", + f"FAIL: expected 6 chain entries, got {chain_len}", file=sys.stderr, ) sys.exit(1) diff --git a/src/cleveragents/application/services/config_service.py b/src/cleveragents/application/services/config_service.py index ad789a643..181dec655 100644 --- a/src/cleveragents/application/services/config_service.py +++ b/src/cleveragents/application/services/config_service.py @@ -40,11 +40,20 @@ class ConfigLevel(Enum): CLI_FLAG = "cli_flag" ENV_VAR = "env_var" + LOCAL = "local" PROJECT = "project" GLOBAL = "global" DEFAULT = "default" +class ConfigScope(Enum): + """File-based configuration scopes for ``--scope`` CLI flag.""" + + GLOBAL = "global" + PROJECT = "project" + LOCAL = "local" + + @dataclass(frozen=True, slots=True) class ConfigEntry: """Metadata for a registered configuration key.""" @@ -1095,6 +1104,40 @@ _build_catalog() _DEFAULT_CONFIG_DIR: Path = Path.home() / ".cleveragents" _DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml" +# Markers that identify a CleverAgents project root directory. +_PROJECT_ROOT_MARKERS: tuple[str, ...] = ("cleveragents.toml", ".cleveragents") + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base* (override wins on conflict). + + Both *base* and *override* remain unmodified; a new dict is returned. + """ + merged: dict[str, Any] = dict(base) + for key, value in override.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def discover_project_root(start: Path | None = None) -> Path | None: + """Walk up from *start* (default ``Path.cwd()``) to find the project root. + + The project root is the nearest ancestor directory that contains one + of the marker files/directories defined in ``_PROJECT_ROOT_MARKERS`` + (``cleveragents.toml`` or ``.cleveragents/``). + + Returns ``None`` when no project root is found. + """ + current = (start or Path.cwd()).resolve() + for directory in (current, *current.parents): + for marker in _PROJECT_ROOT_MARKERS: + if (directory / marker).exists(): + return directory + return None + class ConfigService: """Service providing multi-level config resolution with TOML persistence. @@ -1105,6 +1148,9 @@ class ConfigService: Override for the configuration directory (default ``~/.cleveragents``). config_path: Override for the TOML file path (default ``config_dir / config.toml``). + project_root: + Override for the project root directory. When ``None`` the service + will auto-discover the project root by walking up from ``Path.cwd()``. """ def __init__( @@ -1113,10 +1159,16 @@ class ConfigService: config_dir: Path | None = None, config_path: Path | None = None, event_bus: EventBus | None = None, + project_root: Path | None = ..., # type: ignore[assignment] ) -> None: self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR self._config_path: Path = config_path or (self._config_dir / "config.toml") self._event_bus = event_bus + # ``...`` means "auto-discover"; ``None`` means "no project root". + if project_root is ...: + self._project_root: Path | None = discover_project_root() + else: + self._project_root = project_root # -- public registry API -------------------------------------------------- @@ -1135,6 +1187,27 @@ class ConfigService: """Return all registered key names sorted alphabetically.""" return sorted(_REGISTRY.keys()) + # -- path accessors ------------------------------------------------------- + + @property + def project_root(self) -> Path | None: + """Return the discovered (or overridden) project root directory.""" + return self._project_root + + @property + def project_config_path(self) -> Path | None: + """Return the path to the project-scoped ``config.toml``.""" + if self._project_root is None: + return None + return self._project_root / "config.toml" + + @property + def local_config_path(self) -> Path | None: + """Return the path to the local-scoped ``config.local.toml``.""" + if self._project_root is None: + return None + return self._project_root / "config.local.toml" + # -- TOML file management ------------------------------------------------- def read_config(self) -> dict[str, Any]: @@ -1144,8 +1217,35 @@ class ConfigService: with open(self._config_path, "rb") as fh: return tomllib.load(fh) + def read_project_config(self) -> dict[str, Any]: + """Read the project-scoped ``config.toml`` from the project root.""" + path = self.project_config_path + if path is None or not path.exists(): + return {} + with open(path, "rb") as fh: + return tomllib.load(fh) + + def read_local_config(self) -> dict[str, Any]: + """Read the local-scoped ``config.local.toml`` from the project root.""" + path = self.local_config_path + if path is None or not path.exists(): + return {} + with open(path, "rb") as fh: + return tomllib.load(fh) + + def read_merged_config(self) -> dict[str, Any]: + """Return the three-scope deep-merged configuration. + + Merge order (lowest to highest priority): + ``global < project < local`` + """ + merged = self.read_config() + merged = _deep_merge(merged, self.read_project_config()) + merged = _deep_merge(merged, self.read_local_config()) + return merged + def write_config(self, data: dict[str, Any]) -> None: - """Write *data* to the TOML config file, creating dirs if needed.""" + """Write *data* to the global TOML config file, creating dirs if needed.""" self._config_dir.mkdir(parents=True, exist_ok=True) if self._config_path.exists(): @@ -1160,12 +1260,78 @@ class ConfigService: with open(self._config_path, "w") as fh: tomlkit.dump(doc, fh) - def set_value(self, key: str, value: Any) -> None: - """Persist a single key-value pair into the global TOML config.""" - data = self.read_config() + def write_scoped_config(self, data: dict[str, Any], scope: ConfigScope) -> None: + """Write *data* to the config file for the given *scope*. + + Parameters + ---------- + data: + Key/value pairs to persist. + scope: + Which scope file to write to. + + Raises + ------ + ValueError + If the project root is not discovered and scope is + ``PROJECT`` or ``LOCAL``. + """ + if scope == ConfigScope.GLOBAL: + self.write_config(data) + return + + if self._project_root is None: + msg = ( + f"Cannot write to {scope.value} scope: " + "no project root discovered. " + "Run from within a CleverAgents project directory." + ) + raise ValueError(msg) + + if scope == ConfigScope.PROJECT: + target = self._project_root / "config.toml" + else: + target = self._project_root / "config.local.toml" + + if target.exists(): + with open(target) as fh: + doc = tomlkit.load(fh) + else: + doc = tomlkit.document() + + for key, value in data.items(): + doc[key] = value + + with open(target, "w") as fh: + tomlkit.dump(doc, fh) + + def set_value( + self, key: str, value: Any, *, scope: ConfigScope | None = None + ) -> None: + """Persist a single key-value pair into a scoped TOML config. + + Parameters + ---------- + key: + Configuration key. + value: + Value to persist. + scope: + Target scope. Defaults to ``ConfigScope.GLOBAL``. + """ + effective_scope = scope or ConfigScope.GLOBAL + + if effective_scope == ConfigScope.GLOBAL: + data = self.read_config() + elif effective_scope == ConfigScope.PROJECT: + data = self.read_project_config() + else: + data = self.read_local_config() + old_value = data.get(key) data[key] = value - self.write_config(data) + self.write_scoped_config(data, effective_scope) + if is_sensitive_key(key): old_value = REDACTED value = REDACTED @@ -1178,6 +1344,7 @@ class ConfigService: "key": key, "old_value": old_value, "new_value": value, + "scope": effective_scope.value, }, ) ) @@ -1266,7 +1433,7 @@ class ConfigService: project_name: str | None = None, verbose: bool = False, ) -> ResolvedValue: - """Resolve *key* through the five-level precedence chain. + """Resolve *key* through the six-level precedence chain. Parameters ---------- @@ -1284,6 +1451,11 @@ class ConfigService: ResolvedValue Contains the winning value, its source level, and (if *verbose*) the full resolution chain for display. + + Precedence (highest → lowest):: + + CLI flag > env var > local (config.local.toml) + > project (config.toml) > global > default """ entry = self.validate_key(key) chain: list[dict[str, Any]] = [] @@ -1324,25 +1496,71 @@ class ConfigService: } ) - # Level 3: Project-scoped config + # Level 3: Local config (config.local.toml in project root) + local_val: Any | None = None + if winner_source is None: + local_data = self.read_local_config() + local_val = local_data.get(key) + if local_val is not None and winner_source is None: + coerced = self.validate_type(key, local_val) + if verbose: + chain.append( + { + "source": ConfigLevel.LOCAL.value, + "value": coerced, + "path": str(self.local_config_path or ""), + } + ) + winner_value = coerced + winner_source = ConfigLevel.LOCAL + elif verbose: + chain.append( + { + "source": ConfigLevel.LOCAL.value, + "value": None, + "path": str(self.local_config_path or ""), + } + ) + + # Level 4: Project-scoped config (config.toml in project root, + # or legacy per-project section in global config) project_val: Any | None = None - if project_name and entry.project_scopable and winner_source is None: - config_data = self.read_config() - project_section = config_data.get("project", {}) - if isinstance(project_section, dict): - project_overrides = project_section.get(project_name, {}) - if isinstance(project_overrides, dict): - project_val = project_overrides.get(key) + if winner_source is None: + # First try file-based project config + proj_file_data = self.read_project_config() + project_val = proj_file_data.get(key) + + # Fall back to legacy per-project section in global config + if project_val is None and project_name and entry.project_scopable: + config_data = self.read_config() + project_section = config_data.get("project", {}) + if isinstance(project_section, dict): + project_overrides = project_section.get(project_name, {}) + if isinstance(project_overrides, dict): + project_val = project_overrides.get(key) + if project_val is not None and winner_source is None: coerced = self.validate_type(key, project_val) if verbose: - chain.append({"source": ConfigLevel.PROJECT.value, "value": coerced}) + chain.append( + { + "source": ConfigLevel.PROJECT.value, + "value": coerced, + "path": str(self.project_config_path or ""), + } + ) winner_value = coerced winner_source = ConfigLevel.PROJECT elif verbose: - chain.append({"source": ConfigLevel.PROJECT.value, "value": None}) + chain.append( + { + "source": ConfigLevel.PROJECT.value, + "value": None, + "path": str(self.project_config_path or ""), + } + ) - # Level 4: Global config file + # Level 5: Global config file global_val: Any | None = None if winner_source is None: config_data = self.read_config() @@ -1368,7 +1586,7 @@ class ConfigService: } ) - # Level 5: Default + # Level 6: Default if verbose: chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default}) if winner_source is None: diff --git a/src/cleveragents/application/services/context_service.py b/src/cleveragents/application/services/context_service.py index 70c05ef74..612d27219 100644 --- a/src/cleveragents/application/services/context_service.py +++ b/src/cleveragents/application/services/context_service.py @@ -39,6 +39,7 @@ DEFAULT_IGNORE_PATTERNS = [ "venv", ".env", ".agentsignore", + "config.local.toml", "*.pyc", "*.pyo", "*.pyd", diff --git a/src/cleveragents/cli/commands/config.py b/src/cleveragents/cli/commands/config.py index 3c257500a..9ef828889 100644 --- a/src/cleveragents/cli/commands/config.py +++ b/src/cleveragents/cli/commands/config.py @@ -35,6 +35,7 @@ from rich.table import Table from cleveragents.application.container import get_container from cleveragents.application.services.config_service import ( _REGISTRY, + ConfigScope, ConfigService, ) from cleveragents.cli.formatting import OutputFormat, format_output @@ -183,6 +184,14 @@ def _write_config_file(data: dict[str, Any]) -> None: def config_set( key: Annotated[str, typer.Argument(help="Configuration key (dot-path)")], value: Annotated[str, typer.Argument(help="Value to set")], + scope: Annotated[ + str | None, + typer.Option( + "--scope", + "-s", + help="Config scope: global, project, or local (default: global)", + ), + ] = None, project: Annotated[ str | None, typer.Option("--project", "-p", help="Project name for scoped override"), @@ -191,13 +200,15 @@ def config_set( ) -> None: """Set a configuration value. - Writes the value to ``~/.cleveragents/config.toml``. Parent - directories are created if they do not exist. + Writes the value to the config file for the specified scope. + Scopes: ``global`` (~/.cleveragents/config.toml), + ``project`` (/config.toml), + ``local`` (/config.local.toml). Examples:: agents config set core.log.level DEBUG - agents config set plan.concurrency 8 + agents config set plan.concurrency 8 --scope local agents config set core.automation-profile manual --project local/prod """ normalized = _validate_key(key) @@ -207,11 +218,21 @@ def config_set( # Coerce to the registered type coerced: Any = svc.validate_type(normalized, value) + # Resolve the target scope + config_scope: ConfigScope | None = None + if scope is not None: + try: + config_scope = ConfigScope(scope.lower()) + except ValueError as exc: + raise typer.BadParameter( + f"Invalid scope: '{scope}'. Use 'global', 'project', or 'local'." + ) from exc + # Read previous value config_data = svc.read_config() if project is not None: - # Project-scoped set + # Legacy project-scoped set (--project flag) if not entry.project_scopable: raise typer.BadParameter(f"Key '{normalized}' is not project-scopable.") project_section = config_data.get("project", {}) @@ -225,19 +246,29 @@ def config_set( project_section[project] = proj_overrides config_data["project"] = project_section svc.write_config(config_data) - scope = f"project:{project}" + scope_display = f"project:{project}" + elif config_scope is not None: + # New three-scope set (--scope flag) + if config_scope == ConfigScope.GLOBAL: + previous = config_data.get(normalized) + elif config_scope == ConfigScope.PROJECT: + previous = svc.read_project_config().get(normalized) + else: + previous = svc.read_local_config().get(normalized) + svc.set_value(normalized, coerced, scope=config_scope) + scope_display = config_scope.value else: previous = config_data.get(normalized) config_data[normalized] = coerced svc.write_config(config_data) - scope = "user" + scope_display = "global" result: dict[str, Any] = { "key": normalized, "value": coerced, "previous_value": previous, "source": "config_file", - "scope": scope, + "scope": scope_display, } if fmt != OutputFormat.RICH.value: @@ -251,7 +282,7 @@ def config_set( f"[bold]Value:[/bold] {coerced}\n" f"[bold]Previous:[/bold] {prev_display}\n" f"[bold]Source:[/bold] config_file\n" - f"[bold]Scope:[/bold] {scope}", + f"[bold]Scope:[/bold] {scope_display}", title="Configuration Updated", expand=False, )