feat(config): implement three-scope config resolution with local config file support
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m49s
CI / typecheck (pull_request) Successful in 3m57s
CI / integration_tests (pull_request) Successful in 3m57s
CI / security (pull_request) Successful in 4m6s
CI / unit_tests (pull_request) Successful in 7m19s
CI / docker (pull_request) Successful in 1m18s
CI / coverage (pull_request) Successful in 11m53s
CI / e2e_tests (pull_request) Successful in 21m25s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 18s
CI / helm (push) Successful in 22s
CI / lint (push) Successful in 3m16s
CI / quality (push) Successful in 3m41s
CI / integration_tests (push) Successful in 3m48s
CI / typecheck (push) Successful in 3m54s
CI / unit_tests (push) Successful in 3m54s
CI / security (push) Successful in 4m4s
CI / docker (push) Successful in 1m19s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 14m58s
CI / coverage (push) Successful in 11m55s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 28m23s
CI / benchmark-regression (pull_request) Successful in 54m47s

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
This commit was merged in pull request #1195.
This commit is contained in:
2026-03-29 08:17:29 +00:00
committed by Forgejo
parent 38e05ac45a
commit 7b3fcaf466
11 changed files with 775 additions and 44 deletions
+1
View File
@@ -162,6 +162,7 @@ claude-flow.ps1
hive-mind-prompt-*.txt
.cleveragents/
config.local.toml
.pabotsuitenames
PIPE
@@ -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)
+119
View File
@@ -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"
+6 -5
View File
@@ -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
@@ -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,
+353
View File
@@ -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}"
)
+1 -1
View File
@@ -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}
+9 -4
View File
@@ -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)
@@ -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:
@@ -39,6 +39,7 @@ DEFAULT_IGNORE_PATTERNS = [
"venv",
".env",
".agentsignore",
"config.local.toml",
"*.pyc",
"*.pyo",
"*.pyd",
+39 -8
View File
@@ -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`` (<project-root>/config.toml),
``local`` (<project-root>/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,
)