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
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user