feat(config): add config service with multi-level resolution #474

Merged
freemo merged 1 commits from feature/m3-config-service into master 2026-03-01 05:26:48 +00:00
17 changed files with 2156 additions and 971 deletions
+5 -5
View File
@@ -81,11 +81,11 @@ class ConfigCLIGetSuite:
def time_get_key(self) -> None:
"""Benchmark getting a single config key."""
_runner.invoke(config_app, ["get", "log_level"])
_runner.invoke(config_app, ["get", "core.log.level"])
def time_get_key_json(self) -> None:
"""Benchmark getting a key in JSON format."""
_runner.invoke(config_app, ["get", "log_level", "--format", "json"])
_runner.invoke(config_app, ["get", "core.log.level", "--format", "json"])
class ConfigCLISetSuite:
@@ -106,9 +106,9 @@ class ConfigCLISetSuite:
def time_set_key(self) -> None:
"""Benchmark setting a config value."""
_runner.invoke(config_app, ["set", "log_level", "DEBUG"])
_runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
def time_set_get_roundtrip(self) -> None:
"""Benchmark set followed by get."""
_runner.invoke(config_app, ["set", "log_level", "DEBUG"])
_runner.invoke(config_app, ["get", "log_level"])
_runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
_runner.invoke(config_app, ["get", "core.log.level"])
+50 -99
View File
@@ -1,9 +1,11 @@
"""ASV benchmarks for Config Service resolution chain performance.
Measures the performance of:
- Single key resolution (default, global, env var, CLI flag)
- Single key resolution (default, env var, verbose chain)
- Full registry resolution (resolve_all)
- Key validation and type coercion
- Registry lookup and enumeration
- Memory consumption for bulk operations
"""
from __future__ import annotations
@@ -30,8 +32,8 @@ from cleveragents.application.services.config_service import ( # noqa: E402
)
class ConfigResolutionDefaultSuite:
"""Benchmark default resolution (no overrides)."""
class ConfigResolutionTimeSuite:
"""Benchmark timing for config resolution, validation, and registry ops."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
@@ -41,111 +43,60 @@ class ConfigResolutionDefaultSuite:
)
def teardown(self) -> None:
os.environ.pop("CLEVERAGENTS_LOG_LEVEL", None)
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_default(self) -> None:
"""Benchmark resolving a single key from defaults."""
self._service.resolve("core.log_level")
def time_resolve_single_key(self) -> None:
"""Resolve a single key (core.log.level) with no overrides."""
self._service.resolve("core.log.level")
def time_resolve_default_verbose(self) -> None:
"""Benchmark resolving a single key with verbose chain."""
self._service.resolve("core.log_level", verbose=True)
class ConfigResolutionGlobalSuite:
"""Benchmark resolution from global config file."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
self._service.set_value("core.log_level", "DEBUG")
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_global(self) -> None:
"""Benchmark resolving from global config file."""
self._service.resolve("core.log_level")
class ConfigResolutionEnvSuite:
"""Benchmark resolution from env var."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
os.environ["CLEVERAGENTS_CORE_LOG_LEVEL"] = "WARNING"
def teardown(self) -> None:
os.environ.pop("CLEVERAGENTS_CORE_LOG_LEVEL", None)
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_env(self) -> None:
"""Benchmark resolving from environment variable."""
self._service.resolve("core.log_level")
class ConfigResolutionCLISuite:
"""Benchmark resolution with CLI flag."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_cli(self) -> None:
"""Benchmark resolving with CLI flag (highest priority)."""
self._service.resolve("core.log_level", cli_value="ERROR")
class ConfigResolutionBulkSuite:
"""Benchmark bulk operations."""
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_resolve_all(self) -> None:
"""Benchmark resolving all registered keys."""
def time_resolve_all_keys(self) -> None:
"""Resolve all 103 registered keys via resolve_all()."""
self._service.resolve_all()
def time_registry_keys(self) -> None:
"""Benchmark listing all registered keys."""
def time_resolve_with_env_var(self) -> None:
"""Resolve with an env var set."""
os.environ["CLEVERAGENTS_LOG_LEVEL"] = "DEBUG"
self._service.resolve("core.log.level")
def time_resolve_verbose(self) -> None:
"""Resolve with verbose=True (builds resolution chain)."""
self._service.resolve("core.log.level", verbose=True)
def time_validate_key(self) -> None:
"""Call validate_key() for a known key."""
ConfigService.validate_key("core.log.level")
def time_validate_type_coercion(self) -> None:
"""Call validate_type() for str->int coercion."""
ConfigService.validate_type("core.log.retention-days", "90")
def time_registry_lookup(self) -> None:
"""Look up an entry via get_entry()."""
ConfigService.get_entry("core.log.level")
def time_registered_keys_sorted(self) -> None:
"""Get sorted list of all registered keys."""
ConfigService.registered_keys()
class ConfigValidationSuite:
"""Benchmark key validation and type coercion."""
class ConfigResolutionMemSuite:
"""Benchmark memory consumption for bulk config operations."""
def time_validate_known_key(self) -> None:
"""Benchmark validating a known key."""
ConfigService.validate_key("core.log_level")
def setup(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp())
self._service = ConfigService(
config_dir=self._tmpdir,
config_path=self._tmpdir / "config.toml",
)
def time_validate_type_str(self) -> None:
"""Benchmark type coercion for string."""
ConfigService.validate_type("core.log_level", "DEBUG")
def teardown(self) -> None:
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
def time_validate_type_int(self) -> None:
"""Benchmark type coercion for integer."""
ConfigService.validate_type("core.server_port", "8080")
def mem_resolve_all(self) -> dict:
"""Memory for resolving all keys."""
return self._service.resolve_all()
def time_validate_type_bool(self) -> None:
"""Benchmark type coercion for boolean."""
ConfigService.validate_type("core.debug_enabled", "true")
def mem_registry_copy(self) -> dict:
"""Memory for .registry() (dict copy)."""
return ConfigService.registry()
+144 -104
View File
@@ -1,151 +1,191 @@
# Configuration Resolution Chain
# Configuration Resolution
CleverAgents uses a multi-level configuration resolution chain that determines
the effective value for each configuration key. This document describes the
resolution order, all registered configuration keys, their types, defaults,
and environment variable mappings.
CleverAgents resolves every configuration value through a **5-level precedence
chain**. The first level that supplies a non-`None` value wins.
## Resolution Order
Values are resolved from **highest to lowest** priority. The first level that
provides a non-`None` value wins:
| Priority | Level | Description |
|----------|------------------|------------------------------------------------------|
| 1 | **CLI flag** | Value passed explicitly via `--key=value` on the CLI |
| 2 | **Env var** | Environment variable (`CLEVERAGENTS_<SECTION>_<KEY>`) |
| 3 | **Project scope**| Per-project override in `[project."<name>"]` TOML table |
| 4 | **Global config**| Value in `~/.cleveragents/config.toml` |
| 5 | **Default** | Built-in default from the key registry |
### Example
| Priority | Source | Example |
|----------|--------|---------|
| 1 (highest) | **CLI flag** | `--format json`, `--data-dir /tmp` |
| 2 | **Environment variable** | `CLEVERAGENTS_CORE_LOG_LEVEL=WARNING` |
| 3 | **Project-scoped config** | `[project."myapp"]` table in TOML |
| 4 | **Global config file** | `~/.cleveragents/config.toml` |
| 5 (lowest) | **Built-in default** | Hardcoded in key registry |
```
$ export CLEVERAGENTS_CORE_LOG_LEVEL=WARNING
$ agents config set core.log_level DEBUG # writes to global config
$ agents config get core.log_level
# -> WARNING (env var wins over global config)
# -> WARNING (env var at priority 2 beats global config at priority 4)
```
## Environment Variable Convention
## Key Format
All registered keys map to environment variables following the pattern:
Keys use hierarchical dot-separated names. The segment before the first dot is
the **group**; everything after it is the **key name** within that group.
```
CLEVERAGENTS_<SECTION>_<KEY>
core.log.level
plan.budget.per-plan
provider.openai.api-key
```
Where `<SECTION>` and `<KEY>` are uppercased. For example:
Environment variable mapping follows the pattern
`CLEVERAGENTS_<GROUP>_<KEY>` with dots and hyphens replaced by underscores and
all characters uppercased:
- `core.log_level` -> `CLEVERAGENTS_CORE_LOG_LEVEL`
- `plan.max_retries` -> `CLEVERAGENTS_PLAN_MAX_RETRIES`
- `provider.temperature` -> `CLEVERAGENTS_PROVIDER_TEMPERATURE`
- `core.log.level` `CLEVERAGENTS_CORE_LOG_LEVEL`
- `plan.budget.per-plan` `CLEVERAGENTS_PLAN_BUDGET_PER_PLAN`
## Configuration Keys
**Exception:** `provider.*` credential keys use standard provider env vars
(see [Provider Credentials](#provider-credentials)).
### `core.*` — Core Runtime
## Configuration Groups
| Key | Type | Default | Env Var | Project-Scopable | Description |
|----------------------|--------|------------------------------|------------------------------------|-------------------|-------------------------------|
| `core.log_level` | `str` | `INFO` | `CLEVERAGENTS_CORE_LOG_LEVEL` | Yes | Logging verbosity level |
| `core.debug_enabled` | `bool` | `false` | `CLEVERAGENTS_CORE_DEBUG_ENABLED` | Yes | Enable debug mode |
| `core.env` | `str` | `development` | `CLEVERAGENTS_CORE_ENV` | Yes | Runtime environment name |
| `core.data_dir` | `str` | `data` | `CLEVERAGENTS_CORE_DATA_DIR` | Yes | Base data directory path |
| `core.database_url` | `str` | `sqlite:///cleveragents.db` | `CLEVERAGENTS_CORE_DATABASE_URL` | No | Primary database URL |
| `core.server_host` | `str` | `0.0.0.0` | `CLEVERAGENTS_CORE_SERVER_HOST` | No | Server bind host |
| `core.server_port` | `int` | `8080` | `CLEVERAGENTS_CORE_SERVER_PORT` | No | Server bind port |
| Group | Keys | Scope | Description |
|-------|------|-------|-------------|
| `core.*` | 14 | Mixed | Core system settings — logging, data dirs, database, runtime env |
| `server.*` | 4 | Global only | Server mode — bind host, port, TLS, workers |
| `actor.*` | 5 | Mixed | Actor defaults — timeout, retry, concurrency |
| `plan.*` | 8 | Mixed | Plan execution — budget, retries, auto-apply, timeout |
| `sandbox.*` | 5 | Mixed | Sandbox and checkpointing — strategy, cleanup, max age |
| `index.*` | 12 | Mixed | Code intelligence and indexing — backend, embedding model, dimensions |
| `context.*` | 43 | **All project-scopable** | Context tier defaults — token limits, file caps, inclusion rules |
| `provider.*` | 11 | Global only | LLM provider credentials and model defaults |
| `skills.*` | 1 | Project-scopable | Agent Skills discovery paths |
### `plan.*` — Plan Execution
**Total: 103 registered keys.**
| Key | Type | Default | Env Var | Project-Scopable | Description |
|------------------------|--------|---------|--------------------------------------|-------------------|---------------------------------------|
| `plan.auto_apply` | `bool` | `false` | `CLEVERAGENTS_PLAN_AUTO_APPLY` | Yes | Auto-apply plans on completion |
| `plan.max_retries` | `int` | `3` | `CLEVERAGENTS_PLAN_MAX_RETRIES` | Yes | Maximum plan retry count |
| `plan.timeout_seconds` | `int` | `300` | `CLEVERAGENTS_PLAN_TIMEOUT_SECONDS` | Yes | Plan execution timeout in seconds |
## Project-Scopable Keys
### `provider.*` — LLM Provider
Keys marked as project-scopable can be overridden per-project in the
`[project."<name>"]` TOML table. When a project name is active, the resolver
checks this table at priority 3 before falling back to the global value.
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------|---------|---------|----------------------------------------|-------------------|-------------------------------|
| `provider.default_provider` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` | No | Default LLM provider name |
| `provider.default_model` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` | No | Default LLM model name |
| `provider.temperature` | `float` | `0.7` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` | Yes | LLM sampling temperature |
| `provider.max_tokens` | `int` | `4096` | `CLEVERAGENTS_PROVIDER_MAX_TOKENS` | Yes | Max tokens per LLM response |
**Fully project-scopable groups:**
### `sandbox.*` — Sandbox Isolation
- `context.*`all 43 keys
| Key | Type | Default | Env Var | Project-Scopable | Description |
|------------------------|--------|----------------|--------------------------------------|-------------------|---------------------------------------|
| `sandbox.strategy` | `str` | `git_worktree` | `CLEVERAGENTS_SANDBOX_STRATEGY` | Yes | Default sandbox strategy |
| `sandbox.auto_cleanup` | `bool` | `true` | `CLEVERAGENTS_SANDBOX_AUTO_CLEANUP` | Yes | Auto-clean sandbox after use |
| `sandbox.max_age_hours`| `int` | `48` | `CLEVERAGENTS_SANDBOX_MAX_AGE_HOURS` | Yes | Max sandbox age before cleanup |
**Partially project-scopable groups:**
### `context.*` — Context Management
- `core.*``log.level`, `debug.enabled`, `env`, `data-dir`
- `actor.*``timeout`, `max-retries`, `concurrency`
- `plan.*``auto-apply`, `max-retries`, `timeout`, `budget.per-plan`
- `sandbox.*``strategy`, `auto-cleanup`, `max-age-hours`
- `index.*``enabled`, `backend`, `embedding-model`, `embedding-dimension`
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------------|--------|----------|-------------------------------------------------|-------------------|--------------------------------------------|
| `context.max_files` | `int` | `100` | `CLEVERAGENTS_CONTEXT_MAX_FILES` | Yes | Maximum context files per session |
| `context.max_tokens` | `int` | `128000` | `CLEVERAGENTS_CONTEXT_MAX_TOKENS` | Yes | Maximum context token window |
| `context.auto_include_gitignore` | `bool` | `true` | `CLEVERAGENTS_CONTEXT_AUTO_INCLUDE_GITIGNORE` | Yes | Auto-include gitignore in context filtering |
**Never project-scopable:**
### `index.*` — Vector Store Indexing
- `server.*`applies globally to the running process
- `provider.*` — credentials are always global
| Key | Type | Default | Env Var | Project-Scopable | Description |
|--------------------------|--------|---------|------------------------------------------|-------------------|---------------------------------|
| `index.enabled` | `bool` | `false` | `CLEVERAGENTS_INDEX_ENABLED` | Yes | Enable vector-store indexing |
| `index.backend` | `str` | `faiss` | `CLEVERAGENTS_INDEX_BACKEND` | Yes | Vector store backend name |
| `index.embedding_model` | `str` | `fake` | `CLEVERAGENTS_INDEX_EMBEDDING_MODEL` | Yes | Embedding model for indexing |
| `index.embedding_dimension` | `int` | `1536` | `CLEVERAGENTS_INDEX_EMBEDDING_DIMENSION` | Yes | Embedding vector dimension |
## CLI Commands
## Validation Rules
### Unknown Keys
Attempting to get or set an unregistered key will produce an actionable error:
### `agents config set`
```
ValueError: Unknown configuration key: 'bogus.key'.
Valid keys include: context.auto_include_gitignore, context.max_files, ...
agents config set <key> <value> [--project <name>]
```
### Type Mismatches
Writes a value to the global config file (or to the project-scoped table when
`--project` is supplied). The value is coerced to the key's registered type.
Values are coerced to the registered type. If coercion fails, an actionable
error is raised:
### `agents config get`
```
TypeError: Type mismatch for key 'core.server_port': expected int,
got str with value 'not_a_number'.
agents config get <key> [--verbose] [--project <name>]
```
Boolean coercion accepts: `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
| Flag | Effect |
|------|--------|
| `--verbose` | Print the full resolution chain showing every level |
| `--project` | Resolve as if inside the named project |
## TOML File Location
The global configuration file is stored at:
Verbose output example:
```
~/.cleveragents/config.toml
```
Parent directories are created automatically on first write. Project-scoped
overrides live under `[project."<name>"]` tables within the same file.
## Verbose Mode
When `--verbose` is passed to `config get`, the full resolution chain is
displayed, showing which level provided (or could provide) the value:
```
$ agents config get core.log_level --verbose
Key: core.log_level
$ agents config get core.log.level --verbose
Key: core.log.level
Value: INFO
Source: default
Resolution chain (highest -> lowest priority):
cli_flag -> (not set)
env_var -> (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
project -> (not set)
global -> (not set) [~/.cleveragents/config.toml]
default -> INFO <-- active
Resolution chain (highest lowest):
cli_flag (not set)
env_var (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
project (not set)
global (not set) [~/.cleveragents/config.toml]
default INFO active
```
### `agents config list`
```
agents config list [pattern] [--filter-values] [--show-secrets] [--project <name>]
```
| Flag | Effect |
|------|--------|
| `pattern` | Glob filter on key names (e.g. `core.*`, `plan.budget.*`) |
| `--filter-values` | Only show keys whose resolved value differs from the default |
| `--show-secrets` | Unmask secret values (provider keys are masked by default) |
| `--project` | Resolve values in the context of the named project |
## TOML File Format
`~/.cleveragents/config.toml`:
```toml
[core]
log_level = "DEBUG"
data_dir = "/var/lib/cleveragents"
[plan]
auto_apply = true
max_retries = 5
[sandbox]
strategy = "container"
[index]
enabled = true
backend = "faiss"
embedding_model = "text-embedding-3-small"
# Project-scoped overrides
[project."myapp"]
core.log_level = "WARNING"
plan.auto_apply = false
context.max_tokens = 64000
[project."data-pipeline"]
plan.budget.per_plan = 2.50
sandbox.strategy = "git_worktree"
```
Parent directories are created automatically on the first write.
## Provider Credentials
Provider keys use **standard provider environment variable names**, not the
`CLEVERAGENTS_*` convention. This avoids requiring users to duplicate
credentials under a project-specific prefix.
| Key | Environment Variable |
|-----|---------------------|
| `provider.openai.api-key` | `OPENAI_API_KEY` |
| `provider.anthropic.api-key` | `ANTHROPIC_API_KEY` |
| `provider.google.api-key` | `GOOGLE_API_KEY` |
| `provider.azure.api-key` | `AZURE_OPENAI_API_KEY` |
| `provider.default-provider` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` |
| `provider.default-model` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` |
| `provider.temperature` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` |
Provider credential values are **masked** in `config list` output by default.
Use `--show-secrets` to reveal them.
## Validation
- **Unknown keys** produce an actionable error listing valid keys.
- **Type mismatches** raise `TypeError` with the expected type and actual value.
- Boolean coercion accepts `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
+20 -20
View File
@@ -8,15 +8,15 @@ Feature: Config CLI commands
# --- SET ---
Scenario: Set a configuration value
When I run config set "log_level" "DEBUG"
When I run config set "core.log.level" "DEBUG"
Then the config set should succeed
And the config set output should contain key "log_level"
And the config set output should contain key "core.log.level"
And the config set output should contain value "DEBUG"
Scenario: Set a configuration value with dot-path
When I run config set "server.port" "9090"
Scenario: Set a configuration value with underscore normalization
When I run config set "core_log_level" "INFO"
Then the config set should succeed
And the config set output should contain key "server_port"
And the config set output should contain key "core.log.level"
Scenario: Set an unknown key fails
When I run config set "nonexistent_key" "value"
@@ -24,20 +24,20 @@ Feature: Config CLI commands
# --- GET ---
Scenario: Get a configuration value
When I run config get "log_level"
When I run config get "core.log.level" with verbose
Then the config get should succeed
And the config get output should contain key "log_level"
And the config get output should contain key "core.log.level"
And the config get output should show the resolution chain
Scenario: Get a value after setting it
Given I have set config "log_level" to "WARNING"
When I run config get "log_level"
Given I have set config "core.log.level" to "WARNING"
When I run config get "core.log.level"
Then the config get should succeed
Scenario: Get with dot-path alias
When I run config get "server.port"
Scenario: Get with underscore alias
When I run config get "plan_concurrency"
Then the config get should succeed
And the config get output should contain key "server_port"
And the config get output should contain key "plan.concurrency"
Scenario: Get an unknown key fails
When I run config get "totally_bogus_key"
@@ -50,12 +50,12 @@ Feature: Config CLI commands
And the config list output should contain multiple settings
Scenario: List with key regex filter
When I run config list "log.*"
When I run config list "core\.log.*"
Then the config list should succeed
And every listed key should match "log.*"
And every listed key should match "core\.log.*"
Scenario: List with value regex filter
When I run config list with --filter-values "INFO"
When I run config list with --filter-values "FATAL"
Then the config list should succeed
Scenario: List with invalid key regex fails
@@ -77,15 +77,15 @@ Feature: Config CLI commands
# --- RESOLUTION CHAIN ---
Scenario: Resolution chain shows default source
When I run config get key "log_level" formatted as "json"
When I run config get key "core.log.level" formatted as "json"
Then the resolution chain should include "default" source
# --- SET/GET ROUNDTRIP ---
Scenario: Set and get roundtrip
Given I have set config "debug_enabled" to "true"
When I run config get key "debug_enabled" formatted as "json"
Given I have set config "core.log.file-enabled" to "true"
When I run config get key "core.log.file-enabled" formatted as "json"
Then the config get should succeed
And the get value source should be "config_file"
And the get value source should be "global"
# --- FORMAT ---
Scenario: Config list with JSON format
@@ -94,6 +94,6 @@ Feature: Config CLI commands
And the output should be valid JSON
Scenario: Config get with JSON format
When I run config get key "env" formatted as "json"
When I run config get key "core.format" formatted as "json"
Then the config get should succeed
And the output should be valid JSON
+41 -42
View File
@@ -7,21 +7,21 @@ Feature: Config CLI safety-net coverage
# _normalize_key (L91-96)
# =====================================================================
Scenario: safety-net _normalize_key converts dots to underscores
When the safety-net normalizer processes key "server.port"
Then the safety-net normalized result should be "server_port"
Scenario: safety-net _normalize_key returns registry key as-is
When the safety-net normalizer processes key "core.log.level"
Then the safety-net normalized result should be "core.log.level"
Scenario: safety-net _normalize_key converts dashes to underscores
When the safety-net normalizer processes key "server-port"
Then the safety-net normalized result should be "server_port"
Scenario: safety-net _normalize_key converts underscores to dots for registry match
When the safety-net normalizer processes key "plan_concurrency"
Then the safety-net normalized result should be "plan.concurrency"
Scenario: safety-net _normalize_key lowercases and strips whitespace
When the safety-net normalizer processes key " LOG_LEVEL "
Then the safety-net normalized result should be "log_level"
Scenario: safety-net _normalize_key strips whitespace
When the safety-net normalizer processes key " core.log.level "
Then the safety-net normalized result should be "core.log.level"
Scenario: safety-net _normalize_key handles mixed separators
When the safety-net normalizer processes key "My.Log-Level"
Then the safety-net normalized result should be "my_log_level"
Scenario: safety-net _normalize_key returns unrecognized key as-is
When the safety-net normalizer processes key "unknown_random_key"
Then the safety-net normalized result should be "unknown_random_key"
# =====================================================================
# _is_secret_key (L116-118)
@@ -116,8 +116,8 @@ Feature: Config CLI safety-net coverage
Then the safety-net validator should raise BadParameter with "Unknown configuration key"
Scenario: safety-net _validate_key accepts valid key and returns normalized form
When the safety-net validator checks valid key "log.level"
Then the safety-net validator should return "log_level"
When the safety-net validator checks valid key "core.log.level"
Then the safety-net validator should return "core.log.level"
# =====================================================================
# _resolve_source (L163-174) - default path
@@ -125,24 +125,24 @@ Feature: Config CLI safety-net coverage
Scenario: safety-net _resolve_source returns default when no env or file
Given a safety-net isolated temp config directory
When the safety-net source resolver checks key "log_level"
When the safety-net source resolver checks key "core.log.level"
Then the safety-net resolved source should be "default"
Scenario: safety-net _resolve_source returns config_file when key in file
Scenario: safety-net _resolve_source returns env_var when env var is set
Given a safety-net isolated temp config directory
And a safety-net toml config file containing key "log_level" with value "WARNING"
When the safety-net source resolver checks key "log_level"
Then the safety-net resolved source should be "config_file"
And the safety-net env var "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
When the safety-net source resolver checks key "core.log.level"
Then the safety-net resolved source should be "env_var"
# =====================================================================
# _resolution_chain (L177-209)
# =====================================================================
Scenario: safety-net _resolution_chain returns four-entry list
Scenario: safety-net _resolution_chain returns five-entry list
Given a safety-net isolated temp config directory
When the safety-net chain builder builds chain for key "log_level"
Then the safety-net chain should have exactly 4 entries
And the safety-net chain sources should be "cli_flag, env_var, config_file, default"
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"
# =====================================================================
# config_set - type coercion paths (L241-248)
@@ -150,46 +150,45 @@ Feature: Config CLI safety-net coverage
Scenario: safety-net config set coerces boolean true
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "debug_enabled" to value "true" with format "json"
When the safety-net CLI sets key "index.auto-reindex" to value "true" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "value" should be boolean true
Scenario: safety-net config set coerces boolean false
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "debug_enabled" to value "false" with format "json"
When the safety-net CLI sets key "index.auto-reindex" to value "false" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "value" should be boolean false
Scenario: safety-net config set coerces integer value
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "server_port" to value "8080" with format "json"
When the safety-net CLI sets key "plan.concurrency" to value "8080" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "value" should be integer 8080
Scenario: safety-net config set coerces float value
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "server_port" to value "3.14" with format "json"
When the safety-net CLI sets key "plan.budget.warn-threshold" to value "3.14" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "value" should be float 3.14
Scenario: safety-net config set keeps string when not numeric or bool
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "json"
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "value" should be string "DEBUG"
Scenario: safety-net config set shows previous value in result
Scenario: safety-net config set captures previous value field
Given a safety-net isolated temp config directory
And the safety-net CLI has previously set "log_level" to "INFO"
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "json"
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "json"
Then the safety-net set output should be valid JSON
And the safety-net set JSON field "previous_value" should be string "INFO"
And the safety-net set JSON should contain a "previous_value" field
Scenario: safety-net config set rich format shows panel output
Given a safety-net isolated temp config directory
When the safety-net CLI sets key "log_level" to value "DEBUG" with format "rich"
When the safety-net CLI sets key "core.log.level" to value "DEBUG" with format "rich"
Then the safety-net set rich output should contain "Configuration Updated"
And the safety-net set rich output should contain "log_level"
And the safety-net set rich output should contain "core.log.level"
# =====================================================================
# config_get - rich format (L319-335)
@@ -197,19 +196,19 @@ Feature: Config CLI safety-net coverage
Scenario: safety-net config get rich format displays panel and chain
Given a safety-net isolated temp config directory
When the safety-net CLI gets key "log_level" with format "rich"
When the safety-net CLI gets key "core.log.level" with format "rich" and verbose
Then the safety-net get rich output should contain "Configuration Value"
And the safety-net get rich output should contain "Resolution chain"
Scenario: safety-net config get yaml format produces valid YAML
Given a safety-net isolated temp config directory
When the safety-net CLI gets key "log_level" with format "yaml"
When the safety-net CLI gets key "core.log.level" with format "yaml"
Then the safety-net get output should be valid YAML
And the safety-net get YAML should contain key "key"
Scenario: safety-net config get json format includes type field
Given a safety-net isolated temp config directory
When the safety-net CLI gets key "log_level" with format "json"
When the safety-net CLI gets key "core.log.level" with format "json" and verbose
Then the safety-net get output should be valid JSON with type field
# =====================================================================
@@ -256,15 +255,15 @@ Feature: Config CLI safety-net coverage
Scenario: safety-net config list masks secret values by default
Given a safety-net isolated temp config directory
And safety-net settings fields include a secret key with a non-pattern value
And the safety-net env var "AZURE_OPENAI_API_KEY" is set to "test-secret-value"
When the safety-net CLI lists all config in json format
Then the safety-net list JSON should contain masked value "****" for the secret key
Then the safety-net list JSON should contain masked "****" for key "provider.azure.api-key"
Scenario: safety-net config list reveals secrets with show-secrets flag
Given a safety-net isolated temp config directory
And safety-net settings fields include a secret key with a non-pattern value
And the safety-net env var "AZURE_OPENAI_API_KEY" is set to "test-secret-value"
When the safety-net CLI lists all config in json format with show-secrets
Then the safety-net list JSON should contain the actual secret value
Then the safety-net list JSON should contain value "test-secret-value" for key "provider.azure.api-key"
# =====================================================================
# config_list - modified flag (L405-407)
@@ -283,5 +282,5 @@ Feature: Config CLI safety-net coverage
Scenario: safety-net config get rich format marks active source in chain
Given a safety-net isolated temp config directory
And a safety-net patched console for capturing rich output
When the safety-net CLI gets key "log_level" with format "rich"
When the safety-net CLI gets key "core.log.level" with format "rich" and verbose
Then the safety-net captured console output should contain "active"
+77 -23
View File
@@ -1,58 +1,112 @@
Feature: Config CLI uncovered branches
Cover missed lines and branches in config.py:
_settings_defaults factory/None paths, _validate_key empty,
_settings_defaults registry path, _validate_key empty,
_write_config_file existing-file path, _resolve_source env path,
config_set/get non-rich formats, config_list empty result.
config_set/get non-rich formats, config_list empty result,
config_set --project flag, config_get --verbose flag.
# -- _settings_defaults with default_factory (L81-85) --
Scenario: config cli branch settings defaults uses default_factory when default is None
Given a config cli branch mocked Settings with a default_factory field
# -- _settings_defaults returns defaults from registry --
Scenario: config cli branch settings defaults returns registry defaults
When I config cli branch call _settings_defaults
Then the config cli branch defaults should contain the factory value
Then the config cli branch defaults should contain registry default values
# -- _settings_defaults with both default and factory None (L87) --
Scenario: config cli branch settings defaults returns None when no default and no factory
Given a config cli branch mocked Settings with a None-only field
# -- _settings_defaults with a mocked registry entry having None default --
Scenario: config cli branch settings defaults returns None for None-default entry
Given a config cli branch mocked registry entry with None default
When I config cli branch call _settings_defaults
Then the config cli branch defaults should contain None for the field
Then the config cli branch defaults should contain None for the mocked key
# -- _validate_key with empty key (L102-103) --
Scenario: config cli branch validate_key rejects empty key
When I config cli branch call _validate_key with an empty key
Then the config cli branch call should raise BadParameter
# -- _write_config_file when file already exists (L145-147) --
# -- _write_config_file when file already exists --
Scenario: config cli branch write_config_file merges into existing file
Given a config cli branch temp config directory
And a config cli branch existing config file with key "log_level" set to "DEBUG"
When I config cli branch write config with key "server_port" set to "9090"
And a config cli branch existing config file with key "core.log.level" set to "DEBUG"
When I config cli branch write config with key "plan.concurrency" set to "4"
Then the config cli branch config file should contain both keys
# -- _resolve_source returns "env" (L169-170) --
Scenario: config cli branch resolve_source returns env when env var is set
# -- _resolve_source returns "env_var" when env var is set --
Scenario: config cli branch resolve_source returns env_var when env var is set
Given a config cli branch temp config directory
And the config cli branch env var "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
When I config cli branch call _resolve_source for "log_level"
Then the config cli branch source should be "env"
When I config cli branch call _resolve_source for "core.log.level"
Then the config cli branch source should be "env_var"
# -- config_set with non-rich format (L261-262) --
# -- config_set with non-rich format --
Scenario: config cli branch config set with json format uses format_output
Given a config cli branch temp config directory
When I config cli branch run config set "log_level" "DEBUG" with format "json"
When I config cli branch run config set "core.log.level" "DEBUG" with format "json"
Then the config cli branch set result should be valid JSON
And the config cli branch set JSON should contain key "key"
And the config cli branch set JSON should contain key "value"
# -- config_get with non-rich format and Path value (L311-312, L314-315) --
# -- config_set with --project flag --
Scenario: config cli branch config set with project flag writes project-scoped value
Given a config cli branch temp config directory
When I config cli branch run config set "core.automation-profile" "manual" with project "local/prod"
Then the config cli branch set result for project should succeed
And the config cli branch set result should contain scope "project:local/prod"
# -- config_get with --verbose flag --
Scenario: config cli branch config get with verbose shows resolution chain
Given a config cli branch temp config directory
When I config cli branch run config get "core.log.level" with verbose
Then the config cli branch get verbose result should succeed
And the config cli branch get verbose output should contain resolution chain
# -- config_get with non-rich format and Path value --
Scenario: config cli branch config get with json format serialises Path values
Given a config cli branch temp config directory
And config cli branch settings_fields returns a Path value for "log_dir"
When I config cli branch run config get "log_dir" with format "json"
And config cli branch resolve returns a Path value for "core.log.dir"
When I config cli branch run config get "core.log.dir" with format "json"
Then the config cli branch get result should be valid JSON
And the config cli branch get JSON value should be a string not a Path
# -- config_list with filter matching nothing (L422-423) --
# -- config_set with bool value coercion --
Scenario: config cli branch config set coerces bool value
Given a config cli branch temp config directory
When I config cli branch run config set "core.log.file-enabled" "false" with format "json"
Then the config cli branch set result should be valid JSON
And the config cli branch set JSON value should be bool false
# -- config_set with int value coercion --
Scenario: config cli branch config set coerces int value
Given a config cli branch temp config directory
When I config cli branch run config set "plan.concurrency" "8" with format "json"
Then the config cli branch set result should be valid JSON
And the config cli branch set JSON value should be int 8
# -- config_set with float value coercion --
Scenario: config cli branch config set coerces float value
Given a config cli branch temp config directory
When I config cli branch run config set "plan.budget.warn-threshold" "0.5" with format "json"
Then the config cli branch set result should be valid JSON
And the config cli branch set JSON value should be float 0.5
# -- config_list with filter matching nothing --
Scenario: config cli branch config list prints message when no matches
Given a config cli branch temp config directory
When I config cli branch run config list with pattern "zzz_nonexistent_pattern_xyz"
Then the config cli branch list output should say no values match
# -- config_list with invalid regex --
Scenario: config cli branch config list with invalid regex fails
Given a config cli branch temp config directory
When I config cli branch run config list with pattern "[invalid"
Then the config cli branch list result should fail with regex error
# -- config_get with unknown key --
Scenario: config cli branch config get with unknown key fails
Given a config cli branch temp config directory
When I config cli branch run config get "totally.bogus.key" with format "json"
Then the config cli branch get result should fail with unknown key error
# -- secret masking in list --
Scenario: config cli branch config list masks secret values
Given a config cli branch temp config directory
When I config cli branch run config list with format "json"
Then the config cli branch list result should be valid JSON
And the config cli branch list JSON should mask api-key values
+69 -57
View File
@@ -9,56 +9,60 @@ Feature: Config service with multi-level resolution
# --- Resolution levels ---
Scenario: Default value is returned when nothing else is set
When I resolve config key "core.log_level"
Then the resolved value should be "INFO"
When I resolve config key "core.log.level"
Then the resolved value should be "FATAL"
And the resolved source should be "default"
Scenario: Global config file overrides default
Given I have written global config key "core.log_level" with value "DEBUG"
When I resolve config key "core.log_level"
Given I have written global config key "core.log.level" with value "DEBUG"
When I resolve config key "core.log.level"
Then the resolved value should be "DEBUG"
And the resolved source should be "global"
Scenario: Environment variable overrides global config
Given I have written global config key "core.log_level" with value "DEBUG"
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log_level"
Given I have written global config key "core.log.level" with value "DEBUG"
And the environment variable "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log.level"
Then the resolved value should be "WARNING"
And the resolved source should be "env_var"
Scenario: CLI flag overrides environment variable
Given the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log_level" with CLI value "ERROR"
Given the environment variable "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
When I resolve config key "core.log.level" with CLI value "ERROR"
Then the resolved value should be "ERROR"
And the resolved source should be "cli_flag"
Scenario: Project-scoped value overrides global config
Given I have written global config key "core.log_level" with value "DEBUG"
And I have written project "myproj" config key "core.log_level" with value "TRACE"
When I resolve config key "core.log_level" for project "myproj"
Then the resolved value should be "TRACE"
Given I have written global config key "core.automation-profile" with value "review"
And I have written project "myproj" config key "core.automation-profile" with value "trusted"
When I resolve config key "core.automation-profile" for project "myproj"
Then the resolved value should be "trusted"
And the resolved source should be "project"
Scenario: Env var still overrides project-scoped value
Given I have written project "myproj" config key "core.log_level" with value "TRACE"
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "ERROR"
When I resolve config key "core.log_level" for project "myproj"
Then the resolved value should be "ERROR"
Given I have written project "myproj" config key "core.automation-profile" with value "trusted"
And the environment variable "CLEVERAGENTS_AUTOMATION_PROFILE" is set to "manual"
When I resolve config key "core.automation-profile" for project "myproj"
Then the resolved value should be "manual"
And the resolved source should be "env_var"
# --- Env var interpolation ---
Scenario: Env var name matches CLEVERAGENTS convention
When I look up the env var for key "core.log_level"
Then the env var name should be "CLEVERAGENTS_CORE_LOG_LEVEL"
When I look up the env var for key "core.log.level"
Then the env var name should be "CLEVERAGENTS_LOG_LEVEL"
Scenario: Env var interpolation for plan keys
When I look up the env var for key "plan.max_retries"
Then the env var name should be "CLEVERAGENTS_PLAN_MAX_RETRIES"
When I look up the env var for key "plan.concurrency"
Then the env var name should be "CLEVERAGENTS_PLAN_CONCURRENCY"
Scenario: Env var interpolation for provider keys
When I look up the env var for key "provider.temperature"
Then the env var name should be "CLEVERAGENTS_PROVIDER_TEMPERATURE"
Scenario: Env var interpolation for plan budget keys
When I look up the env var for key "plan.budget.warn-threshold"
Then the env var name should be "CLEVERAGENTS_PLAN_BUDGET_WARN"
Scenario: Provider keys use custom env var names
When I look up the env var for key "provider.openai.api-key"
Then the env var name should be "OPENAI_API_KEY"
# --- Unknown key rejection ---
@@ -73,37 +77,37 @@ Feature: Config service with multi-level resolution
# --- Type validation ---
Scenario: Integer value is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_SERVER_PORT" is set to "9090"
When I resolve config key "core.server_port"
Given the environment variable "CLEVERAGENTS_PLAN_CONCURRENCY" is set to "9090"
When I resolve config key "plan.concurrency"
Then the resolved value should be integer 9090
Scenario: Boolean value true is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "true"
When I resolve config key "core.debug_enabled"
Given the environment variable "CLEVERAGENTS_LOG_FILE_ENABLED" is set to "true"
When I resolve config key "core.log.file-enabled"
Then the resolved value should be boolean true
Scenario: Boolean value false is coerced from string
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "false"
When I resolve config key "core.debug_enabled"
Given the environment variable "CLEVERAGENTS_LOG_FILE_ENABLED" is set to "false"
When I resolve config key "core.log.file-enabled"
Then the resolved value should be boolean false
Scenario: Float value is coerced from string
Given the environment variable "CLEVERAGENTS_PROVIDER_TEMPERATURE" is set to "0.9"
When I resolve config key "provider.temperature"
Given the environment variable "CLEVERAGENTS_PLAN_BUDGET_WARN" is set to "0.9"
When I resolve config key "plan.budget.warn-threshold"
Then the resolved value should be float 0.9
Scenario: Invalid boolean string raises TypeError
When I attempt to validate type for key "core.debug_enabled" with value "notabool"
When I attempt to validate type for key "core.log.file-enabled" with value "notabool"
Then the config TypeError message should contain "Cannot convert"
Scenario: Invalid integer string raises TypeError
When I attempt to validate type for key "core.server_port" with value "notanumber"
When I attempt to validate type for key "plan.concurrency" with value "notanumber"
Then the config TypeError message should contain "Type mismatch"
# --- Verbose resolution chain ---
Scenario: Verbose mode returns full resolution chain
When I resolve config key "core.log_level" with verbose mode
When I resolve config key "core.log.level" with verbose mode
Then the resolution chain should have 5 entries
And the chain should include source "cli_flag"
And the chain should include source "env_var"
@@ -114,47 +118,55 @@ Feature: Config service with multi-level resolution
# --- Key registry ---
Scenario: Registry contains core keys
Then the registry should contain key "core.log_level"
And the registry should contain key "core.debug_enabled"
And the registry should contain key "core.env"
Then the registry should contain key "core.log.level"
And the registry should contain key "core.log.file-enabled"
And the registry should contain key "core.data-dir"
Scenario: Registry contains plan keys
Then the registry should contain key "plan.auto_apply"
And the registry should contain key "plan.max_retries"
And the registry should contain key "plan.timeout_seconds"
Then the registry should contain key "plan.concurrency"
And the registry should contain key "plan.max-child-depth"
And the registry should contain key "plan.budget.per-plan"
Scenario: Registry contains provider keys
Then the registry should contain key "provider.default_provider"
And the registry should contain key "provider.default_model"
And the registry should contain key "provider.temperature"
Then the registry should contain key "provider.openai.api-key"
And the registry should contain key "provider.anthropic.api-key"
Scenario: Registry contains sandbox keys
Then the registry should contain key "sandbox.strategy"
And the registry should contain key "sandbox.auto_cleanup"
And the registry should contain key "sandbox.max_age_hours"
And the registry should contain key "sandbox.cleanup"
And the registry should contain key "sandbox.checkpoint.enabled"
Scenario: Registry contains context keys
Then the registry should contain key "context.max_files"
And the registry should contain key "context.max_tokens"
Then the registry should contain key "context.hot.max-tokens"
And the registry should contain key "context.warm.max-decisions"
Scenario: Registry contains index keys
Then the registry should contain key "index.enabled"
And the registry should contain key "index.backend"
Then the registry should contain key "index.auto-reindex"
And the registry should contain key "index.text.backend"
Scenario: Registry contains server keys
Then the registry should contain key "server.url"
And the registry should contain key "server.token"
And the registry should contain key "server.sync.auto"
Scenario: Registry contains actor keys
Then the registry should contain key "actor.default.strategy"
And the registry should contain key "actor.default.execution"
# --- TOML file management ---
Scenario: Writing and reading TOML config roundtrips
Given I write config data with key "core.log_level" and value "TRACE"
Given I write config data with key "core.log.level" and value "TRACE"
When I read the config file
Then the config data should contain key "core.log_level" with value "TRACE"
Then the config data should contain key "core.log.level" with value "TRACE"
Scenario: Config directory is auto-created
When I write config data to a new directory
Then the config directory should exist
Scenario: Non-project-scopable keys ignore project scope
Given I have written project "proj" config key "core.database_url" with value "sqlite:///other.db"
When I resolve config key "core.database_url" for project "proj"
Given I have written project "proj" config key "core.data-dir" with value "/tmp/other"
When I resolve config key "core.data-dir" for project "proj"
Then the resolved source should be "default"
# --- Resolve all ---
@@ -166,14 +178,14 @@ Feature: Config service with multi-level resolution
# --- Set value ---
Scenario: Set value persists to TOML file
When I set config value "core.log_level" to "CRITICAL"
When I set config value "core.log.level" to "CRITICAL"
And I read the config file
Then the config data should contain key "core.log_level" with value "CRITICAL"
Then the config data should contain key "core.log.level" with value "CRITICAL"
# --- Entry lookup ---
Scenario: Get entry returns ConfigEntry for valid key
When I get entry for key "core.log_level"
When I get entry for key "core.log.level"
Then the entry should have section "core"
And the entry should have python type "str"
+46 -46
View File
@@ -58,8 +58,8 @@ Feature: ConfigService full coverage
Then it should return a dict with the same keys as the internal registry
Scenario: get_entry returns the entry for a known key
When I call ConfigService.get_entry with "core.log_level"
Then it should return a ConfigEntry with key "core.log_level"
When I call ConfigService.get_entry with "core.log.level"
Then it should return a ConfigEntry with key "core.log.level"
Scenario: get_entry returns None for an unknown key
When I call ConfigService.get_entry with "nonexistent.key"
@@ -67,7 +67,7 @@ Feature: ConfigService full coverage
Scenario: registered_keys returns a sorted list of all keys
When I call ConfigService.registered_keys
Then it should return a sorted list containing "core.log_level"
Then it should return a sorted list containing "core.log.level"
# ---------- read_config ----------
@@ -78,9 +78,9 @@ Feature: ConfigService full coverage
Scenario: read_config parses an existing TOML file
Given a temporary directory for config service
And a TOML config file with key "core.log_level" set to "DEBUG"
And a TOML config file with key "core.log.level" set to "DEBUG"
When I create a ConfigService pointing to that TOML file
Then read_config should return a dict with "core.log_level" equal to "DEBUG"
Then read_config should return a dict with "core.log.level" equal to "DEBUG"
# ---------- write_config ----------
@@ -108,8 +108,8 @@ Feature: ConfigService full coverage
# ---------- validate_key ----------
Scenario: validate_key returns entry for a known key
When I call validate_key with "core.log_level"
Then it should return the ConfigEntry for "core.log_level" without error
When I call validate_key with "core.log.level"
Then it should return the ConfigEntry for "core.log.level" without error
Scenario: validate_key raises ValueError for an unknown key
When I call validate_key with "totally.unknown"
@@ -118,55 +118,55 @@ Feature: ConfigService full coverage
# ---------- validate_type ----------
Scenario: validate_type returns value unchanged when type already matches
When I call validate_type with key "core.log_level" and value "INFO"
When I call validate_type with key "core.log.level" and value "INFO"
Then it should return "INFO" unchanged
Scenario: validate_type coerces a string to int
When I call validate_type with key "core.server_port" and string value "9090"
When I call validate_type with key "plan.concurrency" and string value "9090"
Then it should return the integer 9090
Scenario: validate_type coerces a string to float
When I call validate_type with key "provider.temperature" and string value "0.5"
When I call validate_type with key "plan.budget.warn-threshold" and string value "0.5"
Then it should return the float 0.5
Scenario: validate_type coerces non-string to str
When I call validate_type with key "core.log_level" and integer value 123
When I call validate_type with key "core.log.level" and integer value 123
Then it should return the string "123"
Scenario: validate_type coerces "true" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "true"
When I call validate_type with key "core.log.file-enabled" and string value "true"
Then it should return boolean True
Scenario: validate_type coerces "1" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "1"
When I call validate_type with key "core.log.file-enabled" and string value "1"
Then it should return boolean True
Scenario: validate_type coerces "yes" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "yes"
When I call validate_type with key "core.log.file-enabled" and string value "yes"
Then it should return boolean True
Scenario: validate_type coerces "false" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "false"
When I call validate_type with key "core.log.file-enabled" and string value "false"
Then it should return boolean False
Scenario: validate_type coerces "0" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "0"
When I call validate_type with key "core.log.file-enabled" and string value "0"
Then it should return boolean False
Scenario: validate_type coerces "no" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "no"
When I call validate_type with key "core.log.file-enabled" and string value "no"
Then it should return boolean False
Scenario: validate_type raises TypeError for invalid bool string
When I call validate_type with key "core.debug_enabled" and string value "maybe"
When I call validate_type with key "core.log.file-enabled" and string value "maybe"
Then a TypeError should be raised mentioning "Cannot convert"
Scenario: validate_type raises TypeError for non-coercible int value
When I call validate_type with key "core.server_port" and string value "not_a_number"
When I call validate_type with key "plan.concurrency" and string value "not_a_number"
Then a TypeError should be raised mentioning "Type mismatch"
Scenario: validate_type raises TypeError for non-bool non-string to bool key
When I call validate_type with key "core.debug_enabled" and a list value
When I call validate_type with key "core.log.file-enabled" and a list value
Then a TypeError should be raised mentioning "Type mismatch"
Scenario: validate_type raises ValueError for completely unknown key
@@ -178,85 +178,85 @@ Feature: ConfigService full coverage
Scenario: resolve returns default value when no overrides exist
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with no overrides
Then the resolved value should be "INFO" from source DEFAULT
When I resolve key "core.log.level" with no overrides
Then the resolved value should be "FATAL" from source DEFAULT
Scenario: resolve returns CLI flag value when provided
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with cli_value "TRACE"
When I resolve key "core.log.level" with cli_value "TRACE"
Then the resolved value should be "TRACE" from source CLI_FLAG
Scenario: resolve returns environment variable when no CLI flag
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log_level" with no overrides
And the env var "CLEVERAGENTS_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log.level" with no overrides
Then the resolved value should be "WARNING" from source ENV_VAR
Scenario: CLI flag takes precedence over environment variable
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log_level" with cli_value "TRACE"
And the env var "CLEVERAGENTS_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log.level" with cli_value "TRACE"
Then the resolved value should be "TRACE" from source CLI_FLAG
Scenario: resolve returns project-scoped value when set
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.log_level" with project_name "myproj"
And a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.automation-profile" with project_name "myproj"
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
Scenario: resolve returns global config value when no higher overrides
Given a temporary directory for config service
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
When I resolve key "core.log_level" with no overrides
And a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"
When I resolve key "core.log.level" with no overrides
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
Scenario: resolve populates chain when verbose is True
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
When I resolve key "core.log.level" with verbose True and no overrides
Then the resolved chain should have 5 entries covering all levels
Scenario: resolve verbose chain includes env_name for ENV_VAR level
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 verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"
When I resolve key "core.log.level" with verbose True and no overrides
Then the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_LOG_LEVEL"
Scenario: resolve verbose chain includes path for GLOBAL level
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
When I resolve key "core.log.level" with verbose True and no overrides
Then the verbose chain GLOBAL entry should include the config file path
Scenario: resolve verbose chain shows project value when project-scoped wins
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.log_level" with project_name "myproj" and verbose True
And a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.automation-profile" with project_name "myproj" and verbose True
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
And the verbose chain PROJECT entry should have value "PROJECT_DEBUG"
Scenario: resolve verbose chain shows global value when global config wins
Given a temporary directory for config service
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
When I resolve key "core.log_level" with verbose True and no overrides
And a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"
When I resolve key "core.log.level" with verbose True and no overrides
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
And the verbose chain GLOBAL entry should have value "GLOBAL_WARN"
Scenario: resolve skips project scope for non-scopable keys
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"
When I resolve key "core.database_url" with project_name "myproj"
Then the resolved value should not be "pg://proj"
And a ConfigService with project-scoped config for key "core.data-dir" value "/proj/data" under project "myproj"
When I resolve key "core.data-dir" with project_name "myproj"
Then the resolved value should not be "/proj/data"
And the resolved source should be DEFAULT or GLOBAL
# ---------- env_var_for_key ----------
Scenario: env_var_for_key returns the env var name for a valid key
When I call env_var_for_key with "core.log_level"
Then it should return "CLEVERAGENTS_CORE_LOG_LEVEL"
When I call env_var_for_key with "core.log.level"
Then it should return "CLEVERAGENTS_LOG_LEVEL"
Scenario: env_var_for_key raises ValueError for an unknown key
When I call env_var_for_key with "nonexistent.key"
@@ -273,5 +273,5 @@ Feature: ConfigService full coverage
Scenario: resolve_all applies CLI overrides to matching keys
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I call resolve_all with cli_override "core.log_level" set to "FATAL"
Then the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG
When I call resolve_all with cli_override "core.log.level" set to "FATAL"
Then the resolve_all result for "core.log.level" should have value "FATAL" and source CLI_FLAG
@@ -471,6 +471,13 @@ def step_sn_set_previous(context: Context, expected: str) -> None:
assert val == expected, f"Expected previous '{expected}', got {val!r}"
@then('the safety-net set JSON should contain a "previous_value" field')
def step_sn_set_has_previous(context: Context) -> None:
assert "previous_value" in context._sn_set_json, (
f"Expected 'previous_value' field in: {list(context._sn_set_json.keys())}"
)
@then('the safety-net set rich output should contain "{text}"')
def step_sn_set_rich_contains(context: Context, text: str) -> None:
result = context._sn_result
@@ -488,6 +495,13 @@ def step_sn_cli_get(context: Context, key: str, fmt: str) -> None:
context._sn_result = _runner.invoke(config_app, ["get", key, "--format", fmt])
@when('the safety-net CLI gets key "{key}" with format "{fmt}" and verbose')
def step_sn_cli_get_verbose(context: Context, key: str, fmt: str) -> None:
context._sn_result = _runner.invoke(
config_app, ["get", key, "--format", fmt, "--verbose"]
)
@then('the safety-net get rich output should contain "{text}"')
def step_sn_get_rich_contains(context: Context, text: str) -> None:
result = context._sn_result
@@ -639,6 +653,30 @@ def step_sn_list_unmasked(context: Context) -> None:
)
@then('the safety-net list JSON should contain masked "****" for key "{key}"')
def step_sn_list_masked_by_key(context: Context, key: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
entries = [e for e in parsed if e.get("key") == key]
assert len(entries) > 0, f"No '{key}' entry found in list output"
assert entries[0]["value"] == "****", (
f"Expected masked ****, got {entries[0]['value']!r}"
)
@then('the safety-net list JSON should contain value "{value}" for key "{key}"')
def step_sn_list_value_by_key(context: Context, value: str, key: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
entries = [e for e in parsed if e.get("key") == key]
assert len(entries) > 0, f"No '{key}' entry found in list output"
assert entries[0]["value"] == value, (
f"Expected '{value}', got {entries[0]['value']!r}"
)
# ===================================================================
# config_list - modified flag (L405-407)
# ===================================================================
+11 -6
View File
@@ -112,6 +112,11 @@ def step_run_config_get(context: Context, key: str) -> None:
context.result = _runner.invoke(config_app, ["get", key])
@when('I run config get "{key}" with verbose')
def step_run_config_get_verbose(context: Context, key: str) -> None:
context.result = _runner.invoke(config_app, ["get", key, "--verbose"])
@when('I run config get key "{key}" formatted as "{fmt}"')
def step_run_config_get_fmt(context: Context, key: str, fmt: str) -> None:
context.result = _runner.invoke(config_app, ["get", key, "--format", fmt])
@@ -195,12 +200,12 @@ def step_config_list_succeed(context: Context) -> None:
@then("the config list output should contain multiple settings")
def step_config_list_multiple(context: Context) -> None:
# The rich table should have many rows; just check for known keys
assert "log_level" in context.result.output, (
f"Expected 'log_level' in output: {context.result.output}"
# The rich table should have many rows; check for known registry keys
assert "core.log.level" in context.result.output, (
f"Expected 'core.log.level' in output: {context.result.output}"
)
assert "server_port" in context.result.output, (
f"Expected 'server_port' in output: {context.result.output}"
assert "plan.concurrency" in context.result.output, (
f"Expected 'plan.concurrency' in output: {context.result.output}"
)
@@ -234,7 +239,7 @@ def step_config_list_fail_regex(context: Context) -> None:
def step_secrets_masked(context: Context) -> None:
output = context.result.output
# If any API key field is shown, it should be masked
if "api_key" in output or "token" in output:
if "api-key" in output or "token" in output:
assert "****" in output, f"Expected masked values (****) in output: {output}"
@@ -1,14 +1,13 @@
"""Step definitions for config_cli_uncovered_branches.feature.
Covers missed lines/branches in cleveragents/cli/commands/config.py:
L81-85 _settings_defaults default_factory path
L87 _settings_defaults both-None path
L102-103 _validate_key empty key
L145-147 _write_config_file existing-file merge
L169-170 _resolve_source env-var path
L261-262 config_set non-rich format
L311-315 config_get non-rich + Path serialisation
L422-423 config_list empty result
_settings_defaults registry defaults path
_validate_key empty key
_write_config_file existing-file merge
_resolve_source env-var path
config_set non-rich format, --project flag, type coercion
config_get non-rich + Path serialisation, --verbose flag
config_list empty result, invalid regex, secret masking
"""
from __future__ import annotations
@@ -20,13 +19,19 @@ import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import typer
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.config_service import (
_REGISTRY,
ConfigEntry,
ConfigLevel,
ResolvedValue,
)
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import (
_resolve_source,
@@ -75,74 +80,83 @@ def _stop_temp_config(context: Context) -> None:
# ===================================================================
# _settings_defaults - default_factory branch (L81-85)
# _settings_defaults - returns defaults from registry
# ===================================================================
@given("a config cli branch mocked Settings with a default_factory field")
def step_mock_settings_factory(context: Context) -> None:
"""Create a mock Settings class whose model_fields include a factory field."""
factory_field = MagicMock()
factory_field.default = None # triggers elif
factory_field.default_factory = lambda: ["from_factory"]
normal_field = MagicMock()
normal_field.default = "normal_val"
normal_field.default_factory = None
context._cfg_branch_mock_fields = {
"factory_key": factory_field,
"normal_key": normal_field,
}
@given("a config cli branch mocked Settings with a None-only field")
def step_mock_settings_none(context: Context) -> None:
"""Field where both .default and .default_factory are None (L87)."""
none_field = MagicMock()
none_field.default = None
none_field.default_factory = None
context._cfg_branch_mock_fields = {"none_key": none_field}
@when("I config cli branch call _settings_defaults")
def step_call_settings_defaults(context: Context) -> None:
mock_settings_cls = MagicMock()
mock_settings_cls.model_fields = context._cfg_branch_mock_fields
# _settings_defaults() does `from cleveragents.config.settings import Settings`
# so we must patch the canonical location that the local import resolves from.
with patch(
"cleveragents.config.settings.Settings",
mock_settings_cls,
):
context._cfg_branch_defaults_real = _settings_defaults()
context._cfg_branch_defaults_real = _settings_defaults()
@then("the config cli branch defaults should contain the factory value")
def step_defaults_factory_value(context: Context) -> None:
@then("the config cli branch defaults should contain registry default values")
def step_defaults_registry_values(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
# factory_key should have the value produced by the lambda
assert "factory_key" in defaults, f"factory_key missing: {defaults}"
assert defaults["factory_key"] == ["from_factory"], (
f"Expected ['from_factory'], got {defaults['factory_key']}"
# core.log.level should have default "FATAL"
assert "core.log.level" in defaults, f"core.log.level missing: {defaults.keys()}"
assert defaults["core.log.level"] == "FATAL", (
f"Expected 'FATAL', got {defaults['core.log.level']}"
)
# normal_key should have its literal default
assert defaults["normal_key"] == "normal_val", (
f"Expected 'normal_val', got {defaults['normal_key']}"
# plan.concurrency should have default 4
assert "plan.concurrency" in defaults, (
f"plan.concurrency missing: {defaults.keys()}"
)
assert defaults["plan.concurrency"] == 4, (
f"Expected 4, got {defaults['plan.concurrency']}"
)
# core.log.file-enabled should have default True
assert "core.log.file-enabled" in defaults, (
f"core.log.file-enabled missing: {defaults.keys()}"
)
assert defaults["core.log.file-enabled"] is True, (
f"Expected True, got {defaults['core.log.file-enabled']}"
)
@then("the config cli branch defaults should contain None for the field")
def step_defaults_none_value(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
assert "none_key" in defaults, f"none_key missing: {defaults}"
assert defaults["none_key"] is None, f"Expected None, got {defaults['none_key']}"
# ===================================================================
# _validate_key - empty key (L102-103)
# _settings_defaults - None default entry (mocked)
# ===================================================================
@given("a config cli branch mocked registry entry with None default")
def step_mock_registry_none_default(context: Context) -> None:
"""Add a temporary registry entry with None as the default value."""
context._cfg_branch_mock_key = "_test_none_default_key"
context._cfg_branch_orig_entry = _REGISTRY.get(context._cfg_branch_mock_key)
_REGISTRY[context._cfg_branch_mock_key] = ConfigEntry(
key=context._cfg_branch_mock_key,
python_type=str,
default=None,
env_var="CLEVERAGENTS_TEST_NONE",
project_scopable=False,
description="Test entry with None default",
section="test",
)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _cleanup_mock_registry(context))
def _cleanup_mock_registry(context: Context) -> None:
key = getattr(context, "_cfg_branch_mock_key", None)
if key and key in _REGISTRY:
orig = getattr(context, "_cfg_branch_orig_entry", None)
if orig is not None:
_REGISTRY[key] = orig
else:
del _REGISTRY[key]
@then("the config cli branch defaults should contain None for the mocked key")
def step_defaults_none_value(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
key = context._cfg_branch_mock_key
assert key in defaults, f"{key} missing: {list(defaults.keys())[:10]}"
assert defaults[key] is None, f"Expected None, got {defaults[key]}"
# ===================================================================
# _validate_key - empty key (L116-120)
# ===================================================================
@@ -166,7 +180,7 @@ def step_validate_key_bad_param(context: Context) -> None:
# ===================================================================
# _write_config_file - existing file merge (L145-147)
# _write_config_file - existing file merge
# ===================================================================
@@ -202,14 +216,18 @@ def step_config_file_both_keys(context: Context) -> None:
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
assert "log_level" in data, f"log_level missing: {data}"
assert "server_port" in data, f"server_port missing: {data}"
assert data["log_level"] == "DEBUG", f"log_level: {data['log_level']}"
assert data["server_port"] == 9090, f"server_port: {data['server_port']}"
assert "core.log.level" in data, f"core.log.level missing: {data}"
assert "plan.concurrency" in data, f"plan.concurrency missing: {data}"
assert data["core.log.level"] == "DEBUG", (
f"core.log.level: {data['core.log.level']}"
)
assert data["plan.concurrency"] == 4, (
f"plan.concurrency: {data['plan.concurrency']}"
)
# ===================================================================
# _resolve_source - env var path (L169-170)
# _resolve_source - env var path
# ===================================================================
@@ -234,7 +252,7 @@ def step_source_equals(context: Context, expected: str) -> None:
# ===================================================================
# config_set - non-rich format (L261-262)
# config_set - non-rich format
# ===================================================================
@@ -264,36 +282,99 @@ def step_set_json_has_key(context: Context, key: str) -> None:
# ===================================================================
# config_get - non-rich + Path serialisation (L311-315)
# config_set - --project flag
# ===================================================================
@given('config cli branch settings_fields returns a Path value for "{key}"')
def step_mock_settings_fields_path(context: Context, key: str) -> None:
"""Patch helpers so config_get sees a Path value and a chain with Paths."""
@when('I config cli branch run config set "{key}" "{value}" with project "{project}"')
def step_run_config_set_project(
context: Context, key: str, value: str, project: str
) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["set", key, value, "--project", project]
)
@then("the config cli branch set result for project should succeed")
def step_set_project_succeed(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
@then('the config cli branch set result should contain scope "{scope}"')
def step_set_result_scope(context: Context, scope: str) -> None:
result = context._cfg_branch_result
assert scope in result.output, f"Expected '{scope}' in output: {result.output}"
# ===================================================================
# config_get - --verbose flag
# ===================================================================
@when('I config cli branch run config get "{key}" with verbose')
def step_run_config_get_verbose(context: Context, key: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["get", key, "--verbose"])
@then("the config cli branch get verbose result should succeed")
def step_get_verbose_succeed(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
@then("the config cli branch get verbose output should contain resolution chain")
def step_get_verbose_chain(context: Context) -> None:
result = context._cfg_branch_result
output = result.output
assert "Resolution chain" in output or "resolution_chain" in output, (
f"Expected resolution chain in output: {output}"
)
# ===================================================================
# config_get - non-rich + Path serialisation
# ===================================================================
@given('config cli branch resolve returns a Path value for "{key}"')
def step_mock_resolve_path(context: Context, key: str) -> None:
"""Patch ConfigService.resolve so config_get sees a Path value."""
path_val = Path("/mock/test/logs")
mock_fields = {key: path_val, "env": "development"}
mock_chain = [
{"source": "cli_flag", "value": None},
{"source": "env_var", "value": None, "env_name": f"CLEVERAGENTS_{key.upper()}"},
{"source": "config_file", "value": None, "path": "/mock/config.toml"},
{"source": "default", "value": Path("/mock/default/logs")}, # Path in chain
]
p1 = patch.object(config_mod, "_settings_fields", return_value=mock_fields)
p2 = patch.object(config_mod, "_resolve_source", return_value="default")
p3 = patch.object(config_mod, "_resolution_chain", return_value=mock_chain)
p4 = patch.object(config_mod, "_validate_key", return_value=key)
mock_resolved = ResolvedValue(
key=key,
value=path_val,
source=ConfigLevel.DEFAULT,
chain=[
{"source": ConfigLevel.CLI_FLAG.value, "value": None},
{
"source": ConfigLevel.ENV_VAR.value,
"value": None,
"env_name": f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}",
},
{"source": ConfigLevel.PROJECT.value, "value": None},
{
"source": ConfigLevel.GLOBAL.value,
"value": None,
"path": "/mock/config.toml",
},
{"source": ConfigLevel.DEFAULT.value, "value": Path("/mock/default/logs")},
],
)
# Patch _validate_key to accept the key, and ConfigService.resolve
p1 = patch.object(config_mod, "_validate_key", return_value=key)
p1.start()
from cleveragents.application.services.config_service import ConfigService
p2 = patch.object(ConfigService, "resolve", return_value=mock_resolved)
p2.start()
p3.start()
p4.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.extend([p1.stop, p2.stop, p3.stop, p4.stop])
context._cleanup_handlers.extend([p1.stop, p2.stop])
@when('I config cli branch run config get "{key}" with format "{fmt}"')
@@ -322,21 +403,45 @@ def step_get_json_value_string(context: Context) -> None:
assert "/mock/test/logs" in data["value"], (
f"Expected path string, got: {data['value']}"
)
# Resolution chain entry with Path should also be serialised
chain = data.get("resolution_chain", [])
default_entry = [e for e in chain if e["source"] == "default"]
assert default_entry, "No 'default' entry in resolution chain"
default_val = default_entry[0]["value"]
assert isinstance(default_val, str), (
f"Chain default value should be str, got {type(default_val)}: {default_val}"
# ===================================================================
# config_set - type coercion (bool, int, float)
# ===================================================================
@then("the config cli branch set JSON value should be bool false")
def step_set_json_bool_false(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] is False, (
f"Expected False, got {parsed['value']} (type {type(parsed['value'])})"
)
assert "/mock/default/logs" in default_val, (
f"Expected default path string, got: {default_val}"
@then("the config cli branch set JSON value should be int 8")
def step_set_json_int_8(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] == 8, (
f"Expected 8, got {parsed['value']} (type {type(parsed['value'])})"
)
assert isinstance(parsed["value"], int), (
f"Expected int, got {type(parsed['value'])}"
)
@then("the config cli branch set JSON value should be float 0.5")
def step_set_json_float_half(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] == 0.5, (
f"Expected 0.5, got {parsed['value']} (type {type(parsed['value'])})"
)
assert isinstance(parsed["value"], float), (
f"Expected float, got {type(parsed['value'])}"
)
# ===================================================================
# config_list - empty result (L422-423)
# config_list - empty result
# ===================================================================
@@ -352,3 +457,61 @@ def step_list_no_match(context: Context) -> None:
assert "No configuration values match" in result.output, (
f"Expected 'No configuration values match' in: {result.output}"
)
# ===================================================================
# config_list - invalid regex
# ===================================================================
@then("the config cli branch list result should fail with regex error")
def step_list_regex_error(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code != 0, (
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
)
# ===================================================================
# config_get - unknown key
# ===================================================================
@then("the config cli branch get result should fail with unknown key error")
def step_get_unknown_key_error(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code != 0, (
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
)
# ===================================================================
# config_list - JSON format + secret masking
# ===================================================================
@when('I config cli branch run config list with format "{fmt}"')
def step_run_config_list_format(context: Context, fmt: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["list", "--format", fmt])
@then("the config cli branch list result should be valid JSON")
def step_list_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, list), f"Expected list, got {type(parsed)}"
context._cfg_branch_list_json = parsed
@then("the config cli branch list JSON should mask api-key values")
def step_list_json_masked(context: Context) -> None:
items = context._cfg_branch_list_json
# Find entries whose keys contain "api-key" or "token"
secret_entries = [e for e in items if "api-key" in e["key"] or "token" in e["key"]]
# Secret entries with non-None values should be masked
for entry in secret_entries:
if entry["value"] is not None:
assert entry["value"] == "****", (
f"Expected masked '****' for {entry['key']}, got: {entry['value']}"
)
+79 -73
View File
@@ -159,7 +159,7 @@ def step_verify_catalog_sections(context: Any) -> None:
@then("the registry should contain at least 20 keys")
def step_verify_catalog_count(context: Any) -> None:
# _build_catalog registers ~24 keys from the source
# _build_catalog registers ~103 keys from the source
assert len(_REGISTRY) >= 20, f"Expected >=20 keys, got {len(_REGISTRY)}"
@@ -222,15 +222,15 @@ def step_verify_registry_copy(context: Any) -> None:
assert context.result is not _REGISTRY
@when('I call ConfigService.get_entry with "core.log_level"')
@when('I call ConfigService.get_entry with "core.log.level"')
def step_call_get_entry_known(context: Any) -> None:
context.result = ConfigService.get_entry("core.log_level")
context.result = ConfigService.get_entry("core.log.level")
@then('it should return a ConfigEntry with key "core.log_level"')
@then('it should return a ConfigEntry with key "core.log.level"')
def step_verify_get_entry_known(context: Any) -> None:
assert isinstance(context.result, ConfigEntry)
assert context.result.key == "core.log_level"
assert context.result.key == "core.log.level"
@when('I call ConfigService.get_entry with "nonexistent.key"')
@@ -248,11 +248,11 @@ def step_call_registered_keys(context: Any) -> None:
context.result = ConfigService.registered_keys()
@then('it should return a sorted list containing "core.log_level"')
@then('it should return a sorted list containing "core.log.level"')
def step_verify_registered_keys(context: Any) -> None:
assert isinstance(context.result, list)
assert context.result == sorted(context.result)
assert "core.log_level" in context.result
assert "core.log.level" in context.result
# ---------------------------------------------------------------------------
@@ -274,11 +274,11 @@ def step_verify_read_empty(context: Any) -> None:
assert data == {}
@given('a TOML config file with key "core.log_level" set to "DEBUG"')
@given('a TOML config file with key "core.log.level" set to "DEBUG"')
def step_write_toml_log_level(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc["core.log_level"] = "DEBUG"
doc["core.log.level"] = "DEBUG"
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
@@ -291,10 +291,10 @@ def step_service_existing_file(context: Any) -> None:
)
@then('read_config should return a dict with "core.log_level" equal to "DEBUG"')
@then('read_config should return a dict with "core.log.level" equal to "DEBUG"')
def step_verify_read_debug(context: Any) -> None:
data = context.svc.read_config()
assert data.get("core.log_level") == "DEBUG"
assert data.get("core.log.level") == "DEBUG"
# ---------------------------------------------------------------------------
@@ -367,20 +367,20 @@ def step_verify_set_value(context: Any) -> None:
# ---------------------------------------------------------------------------
@when('I call validate_key with "core.log_level"')
@when('I call validate_key with "core.log.level"')
def step_call_validate_key_known(context: Any) -> None:
try:
context.result = ConfigService.validate_key("core.log_level")
context.result = ConfigService.validate_key("core.log.level")
context.error = None
except Exception as exc:
context.error = exc
@then('it should return the ConfigEntry for "core.log_level" without error')
@then('it should return the ConfigEntry for "core.log.level" without error')
def step_verify_validate_key_ok(context: Any) -> None:
assert context.error is None
assert isinstance(context.result, ConfigEntry)
assert context.result.key == "core.log_level"
assert context.result.key == "core.log.level"
@when('I call validate_key with "totally.unknown"')
@@ -404,9 +404,9 @@ def step_verify_validate_key_error(context: Any) -> None:
# ---------------------------------------------------------------------------
@when('I call validate_type with key "core.log_level" and value "INFO"')
@when('I call validate_type with key "core.log.level" and value "INFO"')
def step_validate_type_same(context: Any) -> None:
context.result = ConfigService.validate_type("core.log_level", "INFO")
context.result = ConfigService.validate_type("core.log.level", "INFO")
@then('it should return "INFO" unchanged')
@@ -415,9 +415,9 @@ def step_verify_type_same(context: Any) -> None:
assert isinstance(context.result, str)
@when('I call validate_type with key "core.server_port" and string value "9090"')
@when('I call validate_type with key "plan.concurrency" and string value "9090"')
def step_validate_type_str_to_int(context: Any) -> None:
context.result = ConfigService.validate_type("core.server_port", "9090")
context.result = ConfigService.validate_type("plan.concurrency", "9090")
@then("it should return the integer 9090")
@@ -426,9 +426,11 @@ def step_verify_int_coercion(context: Any) -> None:
assert isinstance(context.result, int)
@when('I call validate_type with key "provider.temperature" and string value "0.5"')
@when(
'I call validate_type with key "plan.budget.warn-threshold" and string value "0.5"'
)
def step_validate_type_str_to_float(context: Any) -> None:
context.result = ConfigService.validate_type("provider.temperature", "0.5")
context.result = ConfigService.validate_type("plan.budget.warn-threshold", "0.5")
@then("it should return the float 0.5")
@@ -437,9 +439,9 @@ def step_verify_float_coercion(context: Any) -> None:
assert isinstance(context.result, float)
@when('I call validate_type with key "core.log_level" and integer value 123')
@when('I call validate_type with key "core.log.level" and integer value 123')
def step_validate_type_int_to_str(context: Any) -> None:
context.result = ConfigService.validate_type("core.log_level", 123)
context.result = ConfigService.validate_type("core.log.level", 123)
@then('it should return the string "123"')
@@ -448,9 +450,9 @@ def step_verify_str_coercion(context: Any) -> None:
assert isinstance(context.result, str)
@when('I call validate_type with key "core.debug_enabled" and string value "true"')
@when('I call validate_type with key "core.log.file-enabled" and string value "true"')
def step_validate_type_bool_true(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "true")
context.result = ConfigService.validate_type("core.log.file-enabled", "true")
@then("it should return boolean True")
@@ -458,19 +460,19 @@ def step_verify_bool_true(context: Any) -> None:
assert context.result is True
@when('I call validate_type with key "core.debug_enabled" and string value "1"')
@when('I call validate_type with key "core.log.file-enabled" and string value "1"')
def step_validate_type_bool_one(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "1")
context.result = ConfigService.validate_type("core.log.file-enabled", "1")
@when('I call validate_type with key "core.debug_enabled" and string value "yes"')
@when('I call validate_type with key "core.log.file-enabled" and string value "yes"')
def step_validate_type_bool_yes(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "yes")
context.result = ConfigService.validate_type("core.log.file-enabled", "yes")
@when('I call validate_type with key "core.debug_enabled" and string value "false"')
@when('I call validate_type with key "core.log.file-enabled" and string value "false"')
def step_validate_type_bool_false(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "false")
context.result = ConfigService.validate_type("core.log.file-enabled", "false")
@then("it should return boolean False")
@@ -478,20 +480,20 @@ def step_verify_bool_false(context: Any) -> None:
assert context.result is False
@when('I call validate_type with key "core.debug_enabled" and string value "0"')
@when('I call validate_type with key "core.log.file-enabled" and string value "0"')
def step_validate_type_bool_zero(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "0")
context.result = ConfigService.validate_type("core.log.file-enabled", "0")
@when('I call validate_type with key "core.debug_enabled" and string value "no"')
@when('I call validate_type with key "core.log.file-enabled" and string value "no"')
def step_validate_type_bool_no(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "no")
context.result = ConfigService.validate_type("core.log.file-enabled", "no")
@when('I call validate_type with key "core.debug_enabled" and string value "maybe"')
@when('I call validate_type with key "core.log.file-enabled" and string value "maybe"')
def step_validate_type_bool_invalid(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.debug_enabled", "maybe")
context.result = ConfigService.validate_type("core.log.file-enabled", "maybe")
context.error = None
except TypeError as exc:
context.error = exc
@@ -505,11 +507,11 @@ def step_verify_bool_invalid_error(context: Any) -> None:
@when(
'I call validate_type with key "core.server_port" and string value "not_a_number"'
'I call validate_type with key "plan.concurrency" and string value "not_a_number"'
)
def step_validate_type_int_invalid(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.server_port", "not_a_number")
context.result = ConfigService.validate_type("plan.concurrency", "not_a_number")
context.error = None
except TypeError as exc:
context.error = exc
@@ -522,10 +524,10 @@ def step_verify_type_mismatch_error(context: Any) -> None:
assert "Type mismatch" in str(context.error)
@when('I call validate_type with key "core.debug_enabled" and a list value')
@when('I call validate_type with key "core.log.file-enabled" and a list value')
def step_validate_type_bool_list(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.debug_enabled", [1, 2, 3])
context.result = ConfigService.validate_type("core.log.file-enabled", [1, 2, 3])
context.error = None
except TypeError as exc:
context.error = exc
@@ -561,20 +563,20 @@ def step_service_empty_for_resolve(context: Any) -> None:
)
@when('I resolve key "core.log_level" with no overrides')
@when('I resolve key "core.log.level" with no overrides')
def step_resolve_no_overrides(context: Any) -> None:
context.result = context.svc.resolve("core.log_level")
context.result = context.svc.resolve("core.log.level")
@then('the resolved value should be "INFO" from source DEFAULT')
@then('the resolved value should be "FATAL" from source DEFAULT')
def step_verify_resolve_default(context: Any) -> None:
assert context.result.value == "INFO"
assert context.result.value == "FATAL"
assert context.result.source == ConfigLevel.DEFAULT
@when('I resolve key "core.log_level" with cli_value "TRACE"')
@when('I resolve key "core.log.level" with cli_value "TRACE"')
def step_resolve_cli(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", cli_value="TRACE")
context.result = context.svc.resolve("core.log.level", cli_value="TRACE")
@then('the resolved value should be "TRACE" from source CLI_FLAG')
@@ -602,14 +604,14 @@ def step_verify_resolve_env(context: Any) -> None:
@given(
'a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"'
'a ConfigService with project-scoped config for key "core.automation-profile" value "PROJECT_DEBUG" under project "myproj"'
)
def step_service_project_scoped(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.log_level"] = "PROJECT_DEBUG"
myproj_table["core.automation-profile"] = "PROJECT_DEBUG"
project_table["myproj"] = myproj_table
doc["project"] = project_table
with open(context.config_path, "w") as fh:
@@ -620,9 +622,11 @@ def step_service_project_scoped(context: Any) -> None:
)
@when('I resolve key "core.log_level" with project_name "myproj"')
@when('I resolve key "core.automation-profile" with project_name "myproj"')
def step_resolve_project(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", project_name="myproj")
context.result = context.svc.resolve(
"core.automation-profile", project_name="myproj"
)
@then('the resolved value should be "PROJECT_DEBUG" from source PROJECT')
@@ -631,11 +635,11 @@ def step_verify_resolve_project(context: Any) -> None:
assert context.result.source == ConfigLevel.PROJECT
@given('a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"')
@given('a ConfigService with global config key "core.log.level" set to "GLOBAL_WARN"')
def step_service_global_config(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc["core.log_level"] = "GLOBAL_WARN"
doc["core.log.level"] = "GLOBAL_WARN"
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
context.svc = ConfigService(
@@ -650,9 +654,9 @@ def step_verify_resolve_global(context: Any) -> None:
assert context.result.source == ConfigLevel.GLOBAL
@when('I resolve key "core.log_level" with verbose True and no overrides')
@when('I resolve key "core.log.level" with verbose True and no overrides')
def step_resolve_verbose(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", verbose=True)
context.result = context.svc.resolve("core.log.level", verbose=True)
@then("the resolved chain should have 5 entries covering all levels")
@@ -670,12 +674,12 @@ def step_verify_verbose_chain_length(context: Any) -> None:
@then(
'the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"'
'the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_LOG_LEVEL"'
)
def step_verify_verbose_env_name(context: Any) -> None:
chain = context.result.chain
env_entry = next(e for e in chain if e["source"] == ConfigLevel.ENV_VAR.value)
assert env_entry["env_name"] == "CLEVERAGENTS_CORE_LOG_LEVEL"
assert env_entry["env_name"] == "CLEVERAGENTS_LOG_LEVEL"
@then("the verbose chain GLOBAL entry should include the config file path")
@@ -690,14 +694,14 @@ def step_verify_verbose_global_path(context: Any) -> None:
@given(
'a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"'
'a ConfigService with project-scoped config for key "core.data-dir" value "/proj/data" under project "myproj"'
)
def step_service_project_scoped_nonscopable(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.database_url"] = "pg://proj"
myproj_table["core.data-dir"] = "/proj/data"
project_table["myproj"] = myproj_table
doc["project"] = project_table
with open(context.config_path, "w") as fh:
@@ -708,10 +712,12 @@ def step_service_project_scoped_nonscopable(context: Any) -> None:
)
@when('I resolve key "core.log_level" with project_name "myproj" and verbose True')
@when(
'I resolve key "core.automation-profile" with project_name "myproj" and verbose True'
)
def step_resolve_project_verbose(context: Any) -> None:
context.result = context.svc.resolve(
"core.log_level", project_name="myproj", verbose=True
"core.automation-profile", project_name="myproj", verbose=True
)
@@ -729,14 +735,14 @@ def step_verify_verbose_global_value(context: Any) -> None:
assert global_entry["value"] == "GLOBAL_WARN"
@when('I resolve key "core.database_url" with project_name "myproj"')
@when('I resolve key "core.data-dir" with project_name "myproj"')
def step_resolve_nonscopable(context: Any) -> None:
context.result = context.svc.resolve("core.database_url", project_name="myproj")
context.result = context.svc.resolve("core.data-dir", project_name="myproj")
@then('the resolved value should not be "pg://proj"')
@then('the resolved value should not be "/proj/data"')
def step_verify_not_project_value(context: Any) -> None:
assert context.result.value != "pg://proj"
assert context.result.value != "/proj/data"
@then("the resolved source should be DEFAULT or GLOBAL")
@@ -749,14 +755,14 @@ def step_verify_source_not_project(context: Any) -> None:
# ---------------------------------------------------------------------------
@when('I call env_var_for_key with "core.log_level"')
@when('I call env_var_for_key with "core.log.level"')
def step_call_env_var_for_key_valid(context: Any) -> None:
context.result = ConfigService.env_var_for_key("core.log_level")
context.result = ConfigService.env_var_for_key("core.log.level")
@then('it should return "CLEVERAGENTS_CORE_LOG_LEVEL"')
@then('it should return "CLEVERAGENTS_LOG_LEVEL"')
def step_verify_env_var_for_key(context: Any) -> None:
assert context.result == "CLEVERAGENTS_CORE_LOG_LEVEL"
assert context.result == "CLEVERAGENTS_LOG_LEVEL"
@when('I call env_var_for_key with "nonexistent.key"')
@@ -789,15 +795,15 @@ def step_verify_resolve_all_keys(context: Any) -> None:
assert isinstance(context.result[key], ResolvedValue)
@when('I call resolve_all with cli_override "core.log_level" set to "FATAL"')
@when('I call resolve_all with cli_override "core.log.level" set to "FATAL"')
def step_call_resolve_all_with_overrides(context: Any) -> None:
context.result = context.svc.resolve_all(cli_overrides={"core.log_level": "FATAL"})
context.result = context.svc.resolve_all(cli_overrides={"core.log.level": "FATAL"})
@then(
'the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG'
'the resolve_all result for "core.log.level" should have value "FATAL" and source CLI_FLAG'
)
def step_verify_resolve_all_override(context: Any) -> None:
rv = context.result["core.log_level"]
rv = context.result["core.log.level"]
assert rv.value == "FATAL"
assert rv.source == ConfigLevel.CLI_FLAG
+39 -23
View File
@@ -8,7 +8,15 @@ Suite Teardown Cleanup Test Environment
${HELPER} ${CURDIR}/helper_config_resolution.py
*** Test Cases ***
Config Resolution Returns Default Value
Config Service Registers All Spec Keys
[Documentation] Verify that the registry contains exactly 103 keys (102 spec + 1 skills)
${result}= Run Process ${PYTHON} ${HELPER} registry-key-count cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-registry-key-count-ok
Config Resolution Returns Default
[Documentation] Verify that resolving a key with no overrides returns the default
${result}= Run Process ${PYTHON} ${HELPER} resolve-default cwd=${WORKSPACE}
Log ${result.stdout}
@@ -16,58 +24,66 @@ Config Resolution Returns Default Value
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-default-ok
Config Resolution Uses Global Config File
[Documentation] Verify that a value set in the global TOML file overrides default
Config Resolution With Global Config
[Documentation] Verify that a value written to the TOML file overrides the default
${result}= Run Process ${PYTHON} ${HELPER} resolve-global cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-global-ok
Config Resolution Uses Environment Variable
[Documentation] Verify that env var overrides global config
Config Resolution With Env Var
[Documentation] Verify that an env var overrides the global config value
${result}= Run Process ${PYTHON} ${HELPER} resolve-env cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-env-ok
Config Resolution Uses CLI Flag
[Documentation] Verify that CLI flag has highest priority
Config Resolution With CLI Override
[Documentation] Verify that cli_value has the highest priority in the chain
${result}= Run Process ${PYTHON} ${HELPER} resolve-cli cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-cli-ok
Config Resolution Rejects Unknown Key
[Documentation] Verify that unknown keys raise ValueError
${result}= Run Process ${PYTHON} ${HELPER} resolve-unknown-key cwd=${WORKSPACE}
Config Resolution Project Scope
[Documentation] Verify that project-scoped config is resolved via project_name
${result}= Run Process ${PYTHON} ${HELPER} resolve-project cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-unknown-key-ok
Should Contain ${result.stdout} config-resolution-project-ok
Config Resolution Performs Type Coercion
[Documentation] Verify that string env vars are coerced to proper types
${result}= Run Process ${PYTHON} ${HELPER} resolve-type-coercion cwd=${WORKSPACE}
Config Resolution Verbose Chain
[Documentation] Verify that verbose=True returns all 5 chain entries
${result}= Run Process ${PYTHON} ${HELPER} resolve-verbose-chain cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-type-coercion-ok
Should Contain ${result.stdout} config-resolution-verbose-chain-ok
Config Registry Contains Expected Keys
[Documentation] Verify that the registry has the expected key catalog
${result}= Run Process ${PYTHON} ${HELPER} registry-keys cwd=${WORKSPACE}
Config Env Var For Provider Keys
[Documentation] Verify provider.openai.api-key maps to OPENAI_API_KEY
${result}= Run Process ${PYTHON} ${HELPER} env-var-provider cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-registry-ok
Should Contain ${result.stdout} config-env-var-provider-ok
Config Env Var Naming Convention
[Documentation] Verify CLEVERAGENTS_<SECTION>_<KEY> naming convention
${result}= Run Process ${PYTHON} ${HELPER} env-var-convention cwd=${WORKSPACE}
Config Validate Type Coercion
[Documentation] Verify str to int, float, and bool coercion via validate_type
${result}= Run Process ${PYTHON} ${HELPER} type-coercion cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-resolution-env-convention-ok
Should Contain ${result.stdout} config-type-coercion-ok
Config List Command Integration
[Documentation] Verify that ``agents config list`` outputs all registered keys
${result}= Run Process ${PYTHON} ${HELPER} cli-list-integration cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} config-cli-list-integration-ok
+7 -5
View File
@@ -53,7 +53,7 @@ def _run_with_tmp(args: list[str]) -> Any:
def config_list() -> None:
"""Verify config list outputs settings."""
result = _run_with_tmp(["list"])
if result.exit_code == 0 and "log_level" in result.output:
if result.exit_code == 0 and "core.log.level" in result.output:
print("config-cli-list-ok")
else:
print(f"FAIL: list returned {result.exit_code}", file=sys.stderr)
@@ -83,20 +83,22 @@ def config_set_get_roundtrip() -> None:
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
patch.object(config_mod, "_CONFIG_PATH", tmppath),
):
set_result = runner.invoke(config_app, ["set", "log_level", "DEBUG"])
set_result = runner.invoke(config_app, ["set", "core.log.level", "DEBUG"])
if set_result.exit_code != 0:
print(f"FAIL: set returned {set_result.exit_code}", file=sys.stderr)
print(set_result.output, file=sys.stderr)
sys.exit(1)
get_result = runner.invoke(config_app, ["get", "log_level", "--format", "json"])
get_result = runner.invoke(
config_app, ["get", "core.log.level", "--format", "json"]
)
if get_result.exit_code != 0:
print(f"FAIL: get returned {get_result.exit_code}", file=sys.stderr)
print(get_result.output, file=sys.stderr)
sys.exit(1)
data = json.loads(get_result.output)
if data.get("source") == "config_file":
if data.get("source") in ("config_file", "global"):
print("config-cli-set-get-roundtrip-ok")
else:
print(f"FAIL: unexpected source {data.get('source')}", file=sys.stderr)
@@ -133,7 +135,7 @@ def config_show_secrets() -> None:
def config_list_filter() -> None:
"""Verify config list with regex filter."""
result = _run_with_tmp(["list", "log.*"])
if result.exit_code == 0 and "log_level" in result.output:
if result.exit_code == 0 and "core.log.level" in result.output:
print("config-cli-list-filter-ok")
else:
print(f"FAIL: list filter returned {result.exit_code}", file=sys.stderr)
+191 -64
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from unittest.mock import patch
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
@@ -22,37 +23,62 @@ from cleveragents.application.services.config_service import ( # noqa: E402
ConfigService,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_service() -> tuple[ConfigService, Path]:
"""Create a ConfigService with a temporary directory."""
"""Create a ConfigService backed by an isolated temp directory."""
tmpdir = Path(tempfile.mkdtemp())
svc = ConfigService(config_dir=tmpdir, config_path=tmpdir / "config.toml")
return svc, tmpdir
def _cleanup(tmpdir: Path) -> None:
"""Remove the temp directory ignoring errors."""
shutil.rmtree(str(tmpdir), ignore_errors=True)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def registry_key_count() -> None:
"""Verify that the registry contains exactly 103 keys."""
registry = ConfigService.registry()
count = len(registry)
if count == 103:
print("config-registry-key-count-ok")
else:
print(f"FAIL: expected 103 keys, got {count}", file=sys.stderr)
sys.exit(1)
def resolve_default() -> None:
"""Verify default resolution works."""
"""Verify default resolution with no overrides."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log_level")
if result.value == "INFO" and result.source == ConfigLevel.DEFAULT:
result = svc.resolve("core.log.level")
if result.value == "FATAL" and result.source == ConfigLevel.DEFAULT:
print("config-resolution-default-ok")
else:
print(
f"FAIL: expected INFO/default, got {result.value}/{result.source}",
f"FAIL: expected FATAL/default, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
_cleanup(tmpdir)
def resolve_global() -> None:
"""Verify global config file overrides default."""
"""Verify that a TOML file value overrides the default."""
svc, tmpdir = _make_service()
try:
svc.set_value("core.log_level", "DEBUG")
result = svc.resolve("core.log_level")
svc.set_value("core.log.level", "DEBUG")
result = svc.resolve("core.log.level")
if result.value == "DEBUG" and result.source == ConfigLevel.GLOBAL:
print("config-resolution-global-ok")
else:
@@ -62,18 +88,19 @@ def resolve_global() -> None:
)
sys.exit(1)
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
_cleanup(tmpdir)
def resolve_env() -> None:
"""Verify env var overrides global config."""
"""Verify that env var overrides the global config value."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
env_key = "CLEVERAGENTS_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
svc.set_value("core.log_level", "DEBUG")
# Set a global value first so we can prove env wins
svc.set_value("core.log.level", "DEBUG")
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log_level")
result = svc.resolve("core.log.level")
if result.value == "WARNING" and result.source == ConfigLevel.ENV_VAR:
print("config-resolution-env-ok")
else:
@@ -87,17 +114,19 @@ def resolve_env() -> None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
_cleanup(tmpdir)
def resolve_cli() -> None:
"""Verify CLI flag overrides env var."""
"""Verify that cli_value has the highest priority."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
env_key = "CLEVERAGENTS_LOG_LEVEL"
old_val = os.environ.get(env_key)
try:
# Set all lower levels so we can prove CLI wins
svc.set_value("core.log.level", "DEBUG")
os.environ[env_key] = "WARNING"
result = svc.resolve("core.log_level", cli_value="ERROR")
result = svc.resolve("core.log.level", cli_value="ERROR")
if result.value == "ERROR" and result.source == ConfigLevel.CLI_FLAG:
print("config-resolution-cli-ok")
else:
@@ -111,84 +140,182 @@ def resolve_cli() -> None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
_cleanup(tmpdir)
def resolve_unknown_key() -> None:
"""Verify unknown key raises ValueError."""
def resolve_project() -> None:
"""Verify project-scoped config resolution via project_name."""
svc, tmpdir = _make_service()
try:
svc.resolve("bogus.nonexistent")
print("FAIL: expected ValueError", file=sys.stderr)
sys.exit(1)
except ValueError:
print("config-resolution-unknown-key-ok")
finally:
shutil.rmtree(str(tmpdir), ignore_errors=True)
# Write project-scoped override into the TOML file
import tomlkit
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.automation-profile"] = "full-auto"
project_table["myproject"] = myproj_table
doc["project"] = project_table
svc._config_dir.mkdir(parents=True, exist_ok=True)
with open(svc._config_path, "w") as fh:
tomlkit.dump(doc, fh)
def resolve_type_coercion() -> None:
"""Verify integer type coercion from env var."""
svc, tmpdir = _make_service()
env_key = "CLEVERAGENTS_CORE_SERVER_PORT"
old_val = os.environ.get(env_key)
try:
os.environ[env_key] = "9090"
result = svc.resolve("core.server_port")
if result.value == 9090 and isinstance(result.value, int):
print("config-resolution-type-coercion-ok")
result = svc.resolve("core.automation-profile", project_name="myproject")
if result.value == "full-auto" and result.source == ConfigLevel.PROJECT:
print("config-resolution-project-ok")
else:
print(
f"FAIL: expected 9090 (int), got {result.value} "
f"({type(result.value).__name__})",
f"FAIL: expected full-auto/project, got {result.value}/{result.source}",
file=sys.stderr,
)
sys.exit(1)
finally:
if old_val is None:
os.environ.pop(env_key, None)
_cleanup(tmpdir)
def resolve_verbose_chain() -> None:
"""Verify verbose=True populates all 5 chain entries."""
svc, tmpdir = _make_service()
try:
result = svc.resolve("core.log.level", verbose=True)
chain_len = len(result.chain)
if chain_len == 5:
# Verify the sources in order
expected_sources = [
ConfigLevel.CLI_FLAG.value,
ConfigLevel.ENV_VAR.value,
ConfigLevel.PROJECT.value,
ConfigLevel.GLOBAL.value,
ConfigLevel.DEFAULT.value,
]
actual_sources = [entry["source"] for entry in result.chain]
if actual_sources == expected_sources:
print("config-resolution-verbose-chain-ok")
else:
print(
f"FAIL: chain sources mismatch: {actual_sources}",
file=sys.stderr,
)
sys.exit(1)
else:
os.environ[env_key] = old_val
shutil.rmtree(str(tmpdir), ignore_errors=True)
def registry_keys() -> None:
"""Verify the registry contains expected keys."""
registry = ConfigService.registry()
expected = ["core.log_level", "plan.max_retries", "provider.temperature"]
for key in expected:
if key not in registry:
print(f"FAIL: {key} not in registry", file=sys.stderr)
print(
f"FAIL: expected 5 chain entries, got {chain_len}",
file=sys.stderr,
)
sys.exit(1)
print("config-resolution-registry-ok")
finally:
_cleanup(tmpdir)
def env_var_convention() -> None:
"""Verify env var naming convention."""
env_name = ConfigService.env_var_for_key("core.log_level")
if env_name == "CLEVERAGENTS_CORE_LOG_LEVEL":
print("config-resolution-env-convention-ok")
def env_var_provider() -> None:
"""Verify that provider.openai.api-key maps to OPENAI_API_KEY."""
env_name = ConfigService.env_var_for_key("provider.openai.api-key")
if env_name == "OPENAI_API_KEY":
print("config-env-var-provider-ok")
else:
print(
f"FAIL: expected CLEVERAGENTS_CORE_LOG_LEVEL, got {env_name}",
f"FAIL: expected OPENAI_API_KEY, got {env_name}",
file=sys.stderr,
)
sys.exit(1)
def type_coercion() -> None:
"""Verify str→int, str→float, and str→bool coercion."""
errors: list[str] = []
# str → int
result_int = ConfigService.validate_type("core.log.retention-days", "45")
if result_int != 45 or not isinstance(result_int, int):
errors.append(
f"int coercion: expected 45 (int), got {result_int!r} "
f"({type(result_int).__name__})"
)
# str → float
result_float = ConfigService.validate_type("plan.budget.per-plan", "3.14")
if result_float != 3.14 or not isinstance(result_float, float):
errors.append(
f"float coercion: expected 3.14 (float), got {result_float!r} "
f"({type(result_float).__name__})"
)
# str → bool (true)
result_bool_t = ConfigService.validate_type("core.log.file-enabled", "true")
if result_bool_t is not True:
errors.append(f"bool coercion (true): got {result_bool_t!r}")
# str → bool (false)
result_bool_f = ConfigService.validate_type("core.log.file-enabled", "false")
if result_bool_f is not False:
errors.append(f"bool coercion (false): got {result_bool_f!r}")
if errors:
for err in errors:
print(f"FAIL: {err}", file=sys.stderr)
sys.exit(1)
else:
print("config-type-coercion-ok")
def cli_list_integration() -> None:
"""Verify ``agents config list`` outputs all registered keys."""
from typer.testing import CliRunner
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import app as config_app
runner = CliRunner()
tmpdir = Path(tempfile.mkdtemp())
tmppath = tmpdir / "config.toml"
try:
with (
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
patch.object(config_mod, "_CONFIG_PATH", tmppath),
):
result = runner.invoke(config_app, ["list"])
if result.exit_code != 0:
print(f"FAIL: config list exited {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
output = result.output
# Spot-check several representative keys from different sections
missing: list[str] = []
for key_fragment in ["log.level", "automation-profile", "openai.api-key"]:
if key_fragment not in output:
missing.append(key_fragment)
if missing:
print(
f"FAIL: missing key fragments in output: {missing}",
file=sys.stderr,
)
sys.exit(1)
print("config-cli-list-integration-ok")
finally:
_cleanup(tmpdir)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"registry-key-count": registry_key_count,
"resolve-default": resolve_default,
"resolve-global": resolve_global,
"resolve-env": resolve_env,
"resolve-cli": resolve_cli,
"resolve-unknown-key": resolve_unknown_key,
"resolve-type-coercion": resolve_type_coercion,
"registry-keys": registry_keys,
"env-var-convention": env_var_convention,
"resolve-project": resolve_project,
"resolve-verbose-chain": resolve_verbose_chain,
"env-var-provider": env_var_provider,
"type-coercion": type_coercion,
"cli-list-integration": cli_list_integration,
}
if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
+185 -182
View File
@@ -9,32 +9,34 @@ persisted in a TOML file at ``~/.cleveragents/config.toml``.
|---------------------------------|-----------------------------------------|
| ``agents config set <K> <V>`` | Set a configuration value |
| ``agents config get <K>`` | Get a configuration value |
| ``agents config list [REGEX]`` | List configuration values |
| ``agents config list [PATTERN]``| List configuration values |
## Key Format
Keys correspond to ``Settings`` field names (e.g. ``log_level``,
``server_port``). Dot-path aliases are accepted underscores and dots
are interchangeable (``log.level`` ``log_level``).
Keys use the hierarchical dot-path names defined in the specification
(e.g. ``core.log.level``, ``plan.budget.per-plan``). Resolution is
delegated to :class:`ConfigService` which implements the five-level
precedence chain.
Based on implementation_plan.md task A8.cli.
"""
from __future__ import annotations
import contextlib
import os
import fnmatch
import re
import tomllib
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any, cast
from typing import Annotated, Any
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cleveragents.application.services.config_service import (
_REGISTRY,
ConfigService,
)
from cleveragents.cli.formatting import OutputFormat, format_output
app = typer.Typer(help="Manage configuration settings for CleverAgents.")
@@ -59,58 +61,9 @@ _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)
# ---------------------------------------------------------------------------
def _settings_fields() -> dict[str, Any]:
"""Return a dict of all Settings field names to their current values."""
from cleveragents.config.settings import Settings
instance = Settings()
fields: dict[str, Any] = {}
for field_name in instance.model_fields:
fields[field_name] = getattr(instance, field_name)
return fields
def _settings_defaults() -> dict[str, Any]:
"""Return a dict of Settings field names to their default values."""
from cleveragents.config.settings import Settings
defaults: dict[str, Any] = {}
for name, field_info in Settings.model_fields.items():
if field_info.default is not None:
defaults[name] = field_info.default
elif field_info.default_factory is not None:
factory: Callable[[], Any] = cast(
Callable[[], Any], field_info.default_factory
)
defaults[name] = factory()
else:
defaults[name] = None
return defaults
def _normalize_key(key: str) -> str:
"""Normalize a user-supplied key to the canonical Settings field name.
Dots are replaced with underscores so ``log.level`` becomes ``log_level``.
"""
return key.strip().replace(".", "_").replace("-", "_").lower()
def _validate_key(key: str) -> str:
"""Validate and normalise *key*, raising :class:`typer.BadParameter` on failure."""
normalized = _normalize_key(key)
if not normalized:
raise typer.BadParameter("Key must not be empty.")
from cleveragents.config.settings import Settings
if normalized not in Settings.model_fields:
valid_sample = ", ".join(sorted(Settings.model_fields)[:8])
raise typer.BadParameter(
f"Unknown configuration key: '{key}' "
f"(normalized: '{normalized}'). "
f"Valid keys include: {valid_sample} ..."
)
return normalized
def _get_service() -> ConfigService:
"""Return a ``ConfigService`` wired to the standard config paths."""
return ConfigService(config_dir=_CONFIG_DIR, config_path=_CONFIG_PATH)
def _is_secret_key(key: str) -> bool:
@@ -123,90 +76,96 @@ def _mask_value(value: str) -> str:
return "****"
def _read_config_file() -> dict[str, Any]:
"""Read the TOML config file and return its contents as a flat dict."""
if not _CONFIG_PATH.exists():
return {}
with open(_CONFIG_PATH, "rb") as fh:
return tomllib.load(fh)
# Legacy helpers retained for backward compatibility with tests that
# patch or call them directly. They now delegate to ConfigService.
def _write_config_file(data: dict[str, Any]) -> None:
"""Write *data* to the TOML config file, creating dirs if needed.
def _settings_fields() -> dict[str, Any]:
"""Return all registered config keys mapped to their resolved values."""
svc = _get_service()
results: dict[str, Any] = {}
for key in _REGISTRY:
resolved = svc.resolve(key)
results[key] = resolved.value
return results
Uses ``tomlkit`` to preserve comments and formatting.
def _settings_defaults() -> dict[str, Any]:
"""Return all registered config keys mapped to their default values."""
return {key: entry.default for key, entry in _REGISTRY.items()}
def _normalize_key(key: str) -> str:
"""Pass-through normalisation — keys are matched as-is against the registry.
Backward compatibility: underscores are converted to dots so that
``log_level`` resolves to ``core.log.level`` if there is a single
match. If the key already exists in the registry, it is returned
unchanged.
"""
import tomlkit
stripped = key.strip()
if stripped in _REGISTRY:
return stripped
# Try underscore-to-dot conversion
dotted = stripped.replace("_", ".")
if dotted in _REGISTRY:
return dotted
return stripped
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# If the file already exists, load it via tomlkit so we preserve
# comments and ordering, then update the relevant keys.
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH) as fh:
doc = tomlkit.load(fh)
else:
doc = tomlkit.document()
for key, value in data.items():
doc[key] = value
with open(_CONFIG_PATH, "w") as fh:
tomlkit.dump(doc, fh)
def _validate_key(key: str) -> str:
"""Validate and normalise *key*, raising :class:`typer.BadParameter` on failure."""
normalized = _normalize_key(key)
if not normalized:
raise typer.BadParameter("Key must not be empty.")
if normalized not in _REGISTRY:
valid_sample = ", ".join(sorted(_REGISTRY)[:8])
raise typer.BadParameter(
f"Unknown configuration key: '{key}' "
f"(normalized: '{normalized}'). "
f"Valid keys include: {valid_sample} ..."
)
return normalized
def _env_var_for_key(key: str) -> str:
"""Return the environment-variable name for a Settings key."""
return f"CLEVERAGENTS_{key.upper()}"
"""Return the environment-variable name for a registered key."""
entry = _REGISTRY.get(key)
if entry is not None:
return entry.env_var
return f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}"
def _resolve_source(key: str) -> str:
"""Determine where the current value for *key* comes from.
Resolution order: env var config file default.
"""
env_name = _env_var_for_key(key)
if os.environ.get(env_name):
return "env"
config_data = _read_config_file()
if key in config_data:
return "config_file"
return "default"
"""Determine which level supplied the winning value for *key*."""
svc = _get_service()
try:
resolved = svc.resolve(key)
return resolved.source.value
except (ValueError, KeyError):
return "default"
def _resolution_chain(key: str) -> list[dict[str, Any]]:
"""Build the resolution chain for *key*.
"""Build the resolution chain for *key* via ConfigService."""
svc = _get_service()
try:
resolved = svc.resolve(key, verbose=True)
return resolved.chain
except (ValueError, KeyError):
return []
Returns a list of dicts with ``source`` and ``value`` entries
ordered from highest to lowest priority.
"""
chain: list[dict[str, Any]] = []
# 1. CLI flag (not applicable at read time, placeholder)
chain.append({"source": "cli_flag", "value": None})
def _read_config_file() -> dict[str, Any]:
"""Read the TOML config file and return its contents as a flat dict."""
svc = _get_service()
return svc.read_config()
# 2. Environment variable
env_name = _env_var_for_key(key)
env_val = os.environ.get(env_name)
chain.append({"source": "env_var", "value": env_val, "env_name": env_name})
# 3. Config file
config_data = _read_config_file()
file_val = config_data.get(key)
chain.append(
{
"source": "config_file",
"value": file_val,
"path": str(_CONFIG_PATH),
}
)
# 4. Default
defaults = _settings_defaults()
default_val = defaults.get(key)
chain.append({"source": "default", "value": default_val})
return chain
def _write_config_file(data: dict[str, Any]) -> None:
"""Write *data* to the TOML config file, creating dirs if needed."""
svc = _get_service()
svc.write_config(data)
# ---------------------------------------------------------------------------
@@ -218,6 +177,10 @@ def _resolution_chain(key: str) -> list[dict[str, Any]]:
def config_set(
key: Annotated[str, typer.Argument(help="Configuration key (dot-path)")],
value: Annotated[str, typer.Argument(help="Value to set")],
project: Annotated[
str | None,
typer.Option("--project", "-p", help="Project name for scoped override"),
] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Set a configuration value.
@@ -227,35 +190,48 @@ def config_set(
Examples::
agents config set log_level DEBUG
agents config set server_port 9090
agents config set core.log.level DEBUG
agents config set plan.concurrency 8
agents config set core.automation-profile manual --project local/prod
"""
normalized = _validate_key(key)
svc = _get_service()
entry = _REGISTRY[normalized]
# Coerce to the registered type
coerced: Any = svc.validate_type(normalized, value)
# Read previous value
config_data = _read_config_file()
previous = config_data.get(normalized)
config_data = svc.read_config()
# Coerce basic types
coerced: Any = value
if value.lower() in ("true", "false"):
coerced = value.lower() == "true"
if project is not None:
# Project-scoped set
if not entry.project_scopable:
raise typer.BadParameter(f"Key '{normalized}' is not project-scopable.")
project_section = config_data.get("project", {})
if not isinstance(project_section, dict):
project_section = {}
proj_overrides = project_section.get(project, {})
if not isinstance(proj_overrides, dict):
proj_overrides = {}
previous = proj_overrides.get(normalized)
proj_overrides[normalized] = coerced
project_section[project] = proj_overrides
config_data["project"] = project_section
svc.write_config(config_data)
scope = f"project:{project}"
else:
try:
coerced = int(value)
except ValueError:
with contextlib.suppress(ValueError):
coerced = float(value)
config_data[normalized] = coerced
_write_config_file(config_data)
previous = config_data.get(normalized)
config_data[normalized] = coerced
svc.write_config(config_data)
scope = "user"
result: dict[str, Any] = {
"key": normalized,
"value": coerced,
"previous_value": previous,
"source": "config_file",
"scope": "user",
"scope": scope,
}
if fmt != OutputFormat.RICH.value:
@@ -269,7 +245,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] user",
f"[bold]Scope:[/bold] {scope}",
title="Configuration Updated",
expand=False,
)
@@ -279,38 +255,53 @@ def config_set(
@app.command("get")
def config_get(
key: Annotated[str, typer.Argument(help="Configuration key (dot-path)")],
verbose: Annotated[
bool,
typer.Option("--verbose", "-v", help="Show full resolution chain"),
] = False,
project: Annotated[
str | None,
typer.Option("--project", "-p", help="Project name for scoped resolution"),
] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Get a configuration value with its resolution chain.
Shows the effective value and which source it was resolved from
(CLI flag, environment variable, config file, or default).
(CLI flag, environment variable, project config, global file, or
default).
Use ``--verbose`` to display the full five-level resolution chain.
Examples::
agents config get log_level
agents config get server_port --format json
agents config get core.log.level
agents config get plan.budget.per-plan --verbose
agents config get core.automation-profile --project local/prod
"""
normalized = _validate_key(key)
svc = _get_service()
fields = _settings_fields()
current_value = fields[normalized]
source = _resolve_source(normalized)
chain = _resolution_chain(normalized)
resolved = svc.resolve(
normalized,
project_name=project,
verbose=verbose,
)
result: dict[str, Any] = {
"key": normalized,
"value": current_value,
"source": source,
"type": type(current_value).__name__ if current_value is not None else "None",
"resolution_chain": chain,
"value": resolved.value,
"source": resolved.source.value,
"type": type(resolved.value).__name__ if resolved.value is not None else "None",
}
if verbose:
result["resolution_chain"] = resolved.chain
if fmt != OutputFormat.RICH.value:
# Serialise Path objects to strings for JSON/YAML
if hasattr(current_value, "__fspath__"):
result["value"] = str(current_value)
for entry in result["resolution_chain"]:
if hasattr(result["value"], "__fspath__"):
result["value"] = str(result["value"])
for entry in result.get("resolution_chain", []):
if hasattr(entry.get("value"), "__fspath__"):
entry["value"] = str(entry["value"])
typer.echo(format_output(result, fmt))
@@ -319,27 +310,31 @@ def config_get(
console.print(
Panel(
f"[bold]Key:[/bold] {normalized}\n"
f"[bold]Value:[/bold] {current_value}\n"
f"[bold]Source:[/bold] {source}\n"
f"[bold]Type:[/bold] {type(current_value).__name__}",
f"[bold]Value:[/bold] {resolved.value}\n"
f"[bold]Source:[/bold] {resolved.source.value}\n"
"[bold]Type:[/bold] "
+ (type(resolved.value).__name__ if resolved.value is not None else "None"),
title="Configuration Value",
expand=False,
)
)
console.print("\n[bold]Resolution chain[/bold] (highest → lowest priority):")
for entry in chain:
src = entry["source"]
val = entry.get("value")
marker = " [green]◀ active[/green]" if src == source and val is not None else ""
display_val = str(val) if val is not None else "(not set)"
console.print(f" {src:14s}{display_val}{marker}")
if verbose and resolved.chain:
console.print("\n[bold]Resolution chain[/bold] (highest → lowest priority):")
for chain_entry in resolved.chain:
src = chain_entry["source"]
val = chain_entry.get("value")
is_winner = src == resolved.source.value and val is not None
marker = " [green]◀ active[/green]" if is_winner else ""
display_val = str(val) if val is not None else "(not set)"
console.print(f" {src:14s}{display_val}{marker}")
@app.command("list")
def config_list(
pattern: Annotated[
str | None,
typer.Argument(help="Regex filter for key names"),
typer.Argument(help="Glob or regex filter for key names (e.g. 'plan.*')"),
] = None,
filter_values: Annotated[
str | None,
@@ -349,18 +344,23 @@ def config_list(
bool,
typer.Option("--show-secrets", help="Show secret values unmasked"),
] = False,
project: Annotated[
str | None,
typer.Option("--project", "-p", help="Project name for scoped resolution"),
] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""List all configuration values.
Optionally filter by key name regex and/or value regex.
Secret values (API keys, tokens, passwords) are masked by
default; use ``--show-secrets`` to reveal them.
Optionally filter by key glob pattern (e.g. ``plan.*``, ``context.pipeline.*``)
and/or value regex. Secret values (API keys, tokens, passwords) are
masked by default; use ``--show-secrets`` to reveal them.
Examples::
agents config list
agents config list "log.*"
agents config list "plan.*"
agents config list "context.pipeline.*"
agents config list --filter-values "DEBUG"
agents config list --show-secrets
"""
@@ -382,25 +382,28 @@ def config_list(
console.print(f"[red]Invalid value regex:[/red] {exc}")
raise typer.Exit(code=1) from exc
fields = _settings_fields()
svc = _get_service()
all_resolved = svc.resolve_all(project_name=project)
defaults = _settings_defaults()
settings_list: list[dict[str, Any]] = []
for key in sorted(fields):
# Key filter
if key_re is not None and not key_re.search(key):
for key in sorted(_REGISTRY):
# Key filter: support both glob (fnmatch) and regex
if (
key_re is not None
and not key_re.search(key)
and (pattern is None or not fnmatch.fnmatch(key, pattern))
):
continue
raw_value = fields[key]
resolved = all_resolved[key]
raw_value = resolved.value
str_value = str(raw_value) if raw_value is not None else ""
# Value filter
if val_re is not None and not val_re.search(str_value):
continue
# Determine source
source = _resolve_source(key)
# Modified flag: value differs from default
default_val = defaults.get(key)
modified = raw_value != default_val
@@ -414,7 +417,7 @@ def config_list(
{
"key": key,
"value": display_value,
"source": source,
"source": resolved.source.value,
"modified": modified,
}
)