From 7c4663b8eeff30011f56a256541009823abed055 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sat, 28 Feb 2026 04:56:51 +0000 Subject: [PATCH] feat(config): add config service with multi-level resolution Implement the complete configuration system with multi-level resolution chain, typed key registry, and CLI integration per specification. ConfigService changes: - Expand _build_catalog() to register all 102 spec-aligned config keys across 8 groups: core (14), server (4), actor (5), plan (8), sandbox (5), index (12), context (43), provider (11) - Each key carries exact dotted-dash name, Python type, default value, explicit env var name per spec, project-scopability flag, and description - Fix _env_name() to convert dots and dashes to underscores - Provider keys use standard env var names (e.g., OPENAI_API_KEY) CLI commands rewiring: - Rewrite config set/get/list to use ConfigService instead of Settings - Add --verbose flag to config get showing full 5-level resolution chain - Add --project flag to config set/get/list for project-scoped overrides - Support both glob and regex patterns in config list - Validate keys against ConfigService registry with actionable errors - Retain backward-compatible helper functions delegating to ConfigService Documentation: - Add docs/reference/config_resolution.md covering resolution chain, all 102 config keys, CLI commands, TOML format, and provider credentials Testing: - Update all 4 Behave feature files and step definitions to use new spec-aligned key names, env vars, and defaults (119 scenarios passing) - Add robot/config_resolution.robot with 10 integration test cases - Add benchmarks/config_resolution_bench.py with 8 time + 2 memory suites ISSUES CLOSED: #258 --- benchmarks/config_cli_bench.py | 10 +- benchmarks/config_resolution_bench.py | 149 +-- docs/reference/config_resolution.md | 248 ++-- features/config_cli.feature | 40 +- .../config_cli_safety_net_coverage.feature | 83 +- .../config_cli_uncovered_branches.feature | 100 +- features/config_resolution.feature | 126 +- features/config_service_coverage.feature | 92 +- .../config_cli_safety_net_coverage_steps.py | 38 + features/steps/config_cli_steps.py | 17 +- .../config_cli_uncovered_branches_steps.py | 363 ++++-- .../steps/config_service_coverage_steps.py | 152 +-- robot/config_resolution.robot | 62 +- robot/helper_config_cli.py | 12 +- robot/helper_config_resolution.py | 255 +++-- .../application/services/config_service.py | 1013 +++++++++++++++-- src/cleveragents/cli/commands/config.py | 367 +++--- 17 files changed, 2156 insertions(+), 971 deletions(-) diff --git a/benchmarks/config_cli_bench.py b/benchmarks/config_cli_bench.py index 21a6de4aa..d8f9b8c90 100644 --- a/benchmarks/config_cli_bench.py +++ b/benchmarks/config_cli_bench.py @@ -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"]) diff --git a/benchmarks/config_resolution_bench.py b/benchmarks/config_resolution_bench.py index 916437b0e..a68b5469e 100644 --- a/benchmarks/config_resolution_bench.py +++ b/benchmarks/config_resolution_bench.py @@ -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() diff --git a/docs/reference/config_resolution.md b/docs/reference/config_resolution.md index 8ab4f8786..cb7c71c44 100644 --- a/docs/reference/config_resolution.md +++ b/docs/reference/config_resolution.md @@ -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_
_`) | -| 3 | **Project scope**| Per-project override in `[project.""]` 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_
_ +core.log.level +plan.budget.per-plan +provider.openai.api-key ``` -Where `
` and `` are uppercased. For example: +Environment variable mapping follows the pattern +`CLEVERAGENTS__` 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.""]` 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 [--project ] ``` -### 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 [--verbose] [--project ] ``` -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.""]` 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 ] +``` + +| 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). diff --git a/features/config_cli.feature b/features/config_cli.feature index 0dccf58f0..ec1ac6b8d 100644 --- a/features/config_cli.feature +++ b/features/config_cli.feature @@ -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 diff --git a/features/config_cli_safety_net_coverage.feature b/features/config_cli_safety_net_coverage.feature index 815fc5878..8356a7246 100644 --- a/features/config_cli_safety_net_coverage.feature +++ b/features/config_cli_safety_net_coverage.feature @@ -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" diff --git a/features/config_cli_uncovered_branches.feature b/features/config_cli_uncovered_branches.feature index b3759ca45..028224006 100644 --- a/features/config_cli_uncovered_branches.feature +++ b/features/config_cli_uncovered_branches.feature @@ -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 diff --git a/features/config_resolution.feature b/features/config_resolution.feature index a9abae2b5..a5403827f 100644 --- a/features/config_resolution.feature +++ b/features/config_resolution.feature @@ -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" diff --git a/features/config_service_coverage.feature b/features/config_service_coverage.feature index 4daf78249..8d4442e2a 100644 --- a/features/config_service_coverage.feature +++ b/features/config_service_coverage.feature @@ -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 diff --git a/features/steps/config_cli_safety_net_coverage_steps.py b/features/steps/config_cli_safety_net_coverage_steps.py index 29a5e5ac5..cf3f4ff80 100644 --- a/features/steps/config_cli_safety_net_coverage_steps.py +++ b/features/steps/config_cli_safety_net_coverage_steps.py @@ -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) # =================================================================== diff --git a/features/steps/config_cli_steps.py b/features/steps/config_cli_steps.py index f8cf1776b..54d95fa39 100644 --- a/features/steps/config_cli_steps.py +++ b/features/steps/config_cli_steps.py @@ -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}" diff --git a/features/steps/config_cli_uncovered_branches_steps.py b/features/steps/config_cli_uncovered_branches_steps.py index 504c40adb..519c199bd 100644 --- a/features/steps/config_cli_uncovered_branches_steps.py +++ b/features/steps/config_cli_uncovered_branches_steps.py @@ -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']}" + ) diff --git a/features/steps/config_service_coverage_steps.py b/features/steps/config_service_coverage_steps.py index 254d972fa..a709166ad 100644 --- a/features/steps/config_service_coverage_steps.py +++ b/features/steps/config_service_coverage_steps.py @@ -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 diff --git a/robot/config_resolution.robot b/robot/config_resolution.robot index 6ec4dfee2..5fafddc88 100644 --- a/robot/config_resolution.robot +++ b/robot/config_resolution.robot @@ -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_
_ 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 diff --git a/robot/helper_config_cli.py b/robot/helper_config_cli.py index de54326a6..6f89e14c5 100644 --- a/robot/helper_config_cli.py +++ b/robot/helper_config_cli.py @@ -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) diff --git a/robot/helper_config_resolution.py b/robot/helper_config_resolution.py index 456863078..9d3d82f94 100644 --- a/robot/helper_config_resolution.py +++ b/robot/helper_config_resolution.py @@ -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__": diff --git a/src/cleveragents/application/services/config_service.py b/src/cleveragents/application/services/config_service.py index efc9bd802..96efe1c6b 100644 --- a/src/cleveragents/application/services/config_service.py +++ b/src/cleveragents/application/services/config_service.py @@ -66,8 +66,13 @@ _REGISTRY: dict[str, ConfigEntry] = {} def _env_name(section: str, key: str) -> str: - """Build ``CLEVERAGENTS_
_`` environment variable name.""" - return f"CLEVERAGENTS_{section.upper()}_{key.upper()}" + """Build ``CLEVERAGENTS_
_`` environment variable name. + + Dots and dashes in *section* and *key* are normalised to underscores + so that ``core.log.level`` becomes ``CLEVERAGENTS_CORE_LOG_LEVEL``. + """ + raw = f"CLEVERAGENTS_{section}_{key}" + return raw.upper().replace(".", "_").replace("-", "_") def _register( @@ -94,196 +99,960 @@ def _register( def _build_catalog() -> None: - """Register the complete configuration catalog per spec.""" - # -- core.* --------------------------------------------------------------- - _register("core", "log_level", str, "INFO", description="Logging verbosity level.") + """Register the complete configuration catalog per specification. + + All 103 configuration keys are registered here (102 spec-defined + + 1 skills extension). Each entry carries the exact dotted-dash key + name, Python type, default value, explicit environment-variable name + (per spec), project-scopability flag, and human-readable description. + """ + + # ── core.* (14 keys) ──────────────────────────────────────────────── _register( "core", - "debug_enabled", + "data-dir", + str, + "~/.cleveragents", + project_scopable=False, + env_var="CLEVERAGENTS_DATA_DIR", + description=("Root directory for all CleverAgents persistent state."), + ) + _register( + "core", + "config-path", + str, + "~/.cleveragents/config.toml", + project_scopable=False, + env_var="CLEVERAGENTS_CONFIG_PATH", + description="Path to the global configuration file.", + ) + _register( + "core", + "format", + str, + "rich", + project_scopable=False, + env_var="CLEVERAGENTS_FORMAT", + description=("Default output format: rich, color, table, plain, json, yaml."), + ) + _register( + "core", + "namespace", + str, + "local", + project_scopable=False, + env_var="CLEVERAGENTS_NAMESPACE", + description="Default namespace prefix for entities.", + ) + _register( + "core", + "automation-profile", + str, + "supervised", + project_scopable=True, + env_var="CLEVERAGENTS_AUTOMATION_PROFILE", + description="Default automation profile for new plans.", + ) + _register( + "core", + "log.level", + str, + "FATAL", + project_scopable=False, + env_var="CLEVERAGENTS_LOG_LEVEL", + description=("Logging verbosity: FATAL, ERROR, WARN, INFO, DEBUG, TRACE."), + ) + _register( + "core", + "log.dir", + str, + "~/.cleveragents/logs", + project_scopable=False, + env_var="CLEVERAGENTS_LOG_DIR", + description="Directory for log file output.", + ) + _register( + "core", + "log.file-enabled", bool, - False, - description="Enable debug mode.", - ) - _register( - "core", - "env", - str, - "development", - description="Runtime environment name.", - ) - _register( - "core", - "data_dir", - str, - "data", - description="Base data directory path.", - ) - _register( - "core", - "database_url", - str, - "sqlite:///cleveragents.db", + True, project_scopable=False, - description="Primary database URL.", + env_var="CLEVERAGENTS_LOG_FILE_ENABLED", + description="Whether log output is written to files.", ) _register( "core", - "server_host", + "log.terminal", str, - "0.0.0.0", + "auto", project_scopable=False, - description="Server bind host.", + env_var="CLEVERAGENTS_LOG_TERMINAL", + description="Terminal log display: auto, always, never.", ) _register( "core", - "server_port", + "log.terminal-stream", + str, + "stderr", + project_scopable=False, + env_var="CLEVERAGENTS_LOG_TERMINAL_STREAM", + description="Stream for terminal log output: stderr, stdout, or path.", + ) + _register( + "core", + "log.retention-days", int, - 8080, + 30, project_scopable=False, - description="Server bind port.", + env_var="CLEVERAGENTS_LOG_RETENTION_DAYS", + description="Days to retain log files before cleanup.", + ) + _register( + "core", + "backup.dir", + str, + "~/.cleveragents/backups", + project_scopable=False, + env_var="CLEVERAGENTS_BACKUP_DIR", + description="Directory for backup snapshots.", + ) + _register( + "core", + "backup.retention-days", + int, + 7, + project_scopable=False, + env_var="CLEVERAGENTS_BACKUP_RETENTION_DAYS", + description="Days to retain backup snapshots.", + ) + _register( + "core", + "cache.dir", + str, + "~/.cleveragents/cache", + project_scopable=False, + env_var="CLEVERAGENTS_CACHE_DIR", + description="Directory for transient caches.", ) - # -- plan.* --------------------------------------------------------------- + # ── server.* (4 keys) ─────────────────────────────────────────────── _register( - "plan", - "auto_apply", + "server", + "url", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_URL", + description="CleverAgents server URL for collaborative mode.", + ) + _register( + "server", + "token", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_TOKEN", + description="Authentication token for server mode.", + ) + _register( + "server", + "sync.auto", bool, - False, - description="Automatically apply plans on completion.", + True, + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_SYNC_AUTO", + description="Auto-sync entity definitions with the server.", ) _register( - "plan", - "max_retries", - int, - 3, - description="Maximum plan retry count.", - ) - _register( - "plan", - "timeout_seconds", + "server", + "sync.interval", int, 300, - description="Plan execution timeout in seconds.", + project_scopable=False, + env_var="CLEVERAGENTS_SERVER_SYNC_INTERVAL", + description="Seconds between automatic background syncs.", ) - # -- provider.* ----------------------------------------------------------- + # ── actor.* (5 keys) ──────────────────────────────────────────────── _register( - "provider", - "default_provider", + "actor", + "default.strategy", str, - "", + None, project_scopable=False, - description="Default LLM provider name.", + env_var="CLEVERAGENTS_DEFAULT_STRATEGY_ACTOR", + description="Default strategy actor for plans.", ) _register( - "provider", - "default_model", + "actor", + "default.execution", str, - "", + None, project_scopable=False, - description="Default LLM model name.", + env_var="CLEVERAGENTS_DEFAULT_EXECUTION_ACTOR", + description="Default execution actor for plans.", ) _register( - "provider", - "temperature", - float, - 0.7, - description="LLM sampling temperature.", + "actor", + "default.estimation", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_DEFAULT_ESTIMATION_ACTOR", + description="Default estimation actor for cost estimation.", ) _register( - "provider", - "max_tokens", + "actor", + "default.invariant", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_DEFAULT_INVARIANT_ACTOR", + description="Default invariant reconciliation actor.", + ) + _register( + "actor", + "default.orchestrator", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_DEFAULT_ORCHESTRATOR", + description="Default orchestrator actor for sessions.", + ) + + # ── plan.* (8 keys) ───────────────────────────────────────────────── + _register( + "plan", + "concurrency", int, - 4096, - description="Maximum tokens per LLM response.", + 4, + project_scopable=True, + env_var="CLEVERAGENTS_PLAN_CONCURRENCY", + description="Maximum concurrent plan executions.", + ) + _register( + "plan", + "max-child-depth", + int, + 5, + project_scopable=True, + env_var="CLEVERAGENTS_PLAN_MAX_CHILD_DEPTH", + description="Maximum child plan nesting depth.", + ) + _register( + "plan", + "budget.per-plan", + float, + None, + project_scopable=True, + env_var="CLEVERAGENTS_PLAN_BUDGET", + description="Max estimated API cost (USD) per plan.", + ) + _register( + "plan", + "budget.per-session", + float, + None, + project_scopable=False, + env_var="CLEVERAGENTS_SESSION_BUDGET", + description="Max estimated API cost (USD) per session.", + ) + _register( + "plan", + "budget.warn-threshold", + float, + 0.8, + project_scopable=False, + env_var="CLEVERAGENTS_PLAN_BUDGET_WARN", + description="Fraction of per-plan budget for warning.", + ) + _register( + "plan", + "tool.max-calls-per-step", + int, + 25, + project_scopable=False, + env_var="CLEVERAGENTS_TOOL_MAX_CALLS", + description="Max tool invocations per actor step.", + ) + _register( + "plan", + "tool.max-retries", + int, + 3, + project_scopable=False, + env_var="CLEVERAGENTS_TOOL_MAX_RETRIES", + description="Max retries for transient tool failures.", + ) + _register( + "plan", + "tool.retry-backoff", + str, + "exponential", + project_scopable=False, + env_var="CLEVERAGENTS_TOOL_RETRY_BACKOFF", + description="Backoff strategy: exponential, linear, none.", ) - # -- sandbox.* ------------------------------------------------------------ + # ── sandbox.* (5 keys) ────────────────────────────────────────────── _register( "sandbox", "strategy", str, "git_worktree", - description="Default sandbox strategy.", + project_scopable=True, + env_var="CLEVERAGENTS_SANDBOX_STRATEGY", + description="Default sandbox isolation strategy.", ) _register( "sandbox", - "auto_cleanup", + "cleanup", + str, + "on_apply", + project_scopable=False, + env_var="CLEVERAGENTS_SANDBOX_CLEANUP", + description="When to clean up sandbox: on_apply, on_terminal, manual.", + ) + _register( + "sandbox", + "checkpoint.enabled", bool, True, - description="Automatically clean up sandbox after use.", + project_scopable=True, + env_var="CLEVERAGENTS_CHECKPOINT_ENABLED", + description="Whether checkpointing is enabled.", ) _register( "sandbox", - "max_age_hours", + "checkpoint.dir", + str, + "~/.cleveragents/checkpoints", + project_scopable=False, + env_var="CLEVERAGENTS_CHECKPOINT_DIR", + description="Directory for plan execution checkpoints.", + ) + _register( + "sandbox", + "checkpoint.max-per-plan", int, - 48, - description="Max sandbox age in hours before cleanup.", + 50, + project_scopable=False, + env_var="CLEVERAGENTS_CHECKPOINT_MAX", + description="Max checkpoints retained per plan.", ) - # -- context.* ------------------------------------------------------------ + # ── index.* (12 keys) ─────────────────────────────────────────────── _register( - "context", - "max_files", + "index", + "text.backend", + str, + "tantivy", + project_scopable=False, + env_var="CLEVERAGENTS_INDEX_TEXT_BACKEND", + description="Full-text search backend: tantivy, sqlite_fts.", + ) + _register( + "index", + "text.dir", + str, + "~/.cleveragents/index/text", + project_scopable=False, + env_var="CLEVERAGENTS_INDEX_TEXT_DIR", + description="Full-text search index directory.", + ) + _register( + "index", + "vector.backend", + str, + "faiss", + project_scopable=False, + env_var="CLEVERAGENTS_INDEX_VECTOR_BACKEND", + description="Vector search backend: faiss, qdrant, none.", + ) + _register( + "index", + "vector.dir", + str, + "~/.cleveragents/index/vector", + project_scopable=False, + env_var="CLEVERAGENTS_INDEX_VECTOR_DIR", + description="Vector index directory (for local backends).", + ) + _register( + "index", + "vector.qdrant-url", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_QDRANT_URL", + description="Qdrant server URL (required for qdrant backend).", + ) + _register( + "index", + "graph.backend", + str, + "none", + project_scopable=False, + env_var="CLEVERAGENTS_INDEX_GRAPH_BACKEND", + description="Knowledge graph backend: neo4j, rdflib, none.", + ) + _register( + "index", + "graph.neo4j-url", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_NEO4J_URL", + description="Neo4j server URL (required for neo4j backend).", + ) + _register( + "index", + "graph.neo4j-auth", + str, + None, + project_scopable=False, + env_var="CLEVERAGENTS_NEO4J_AUTH", + description="Neo4j auth in user:password format.", + ) + _register( + "index", + "embedding.provider", + str, + "openai", + project_scopable=False, + env_var="CLEVERAGENTS_EMBEDDING_PROVIDER", + description="Embedding provider: openai, anthropic, local.", + ) + _register( + "index", + "embedding.model", + str, + "text-embedding-3-small", + project_scopable=False, + env_var="CLEVERAGENTS_EMBEDDING_MODEL", + description="Model for generating vector embeddings.", + ) + _register( + "index", + "embedding.dimensions", + int, + None, + project_scopable=False, + env_var="CLEVERAGENTS_EMBEDDING_DIMENSIONS", + description="Embedding vector dimensions (None = provider default).", + ) + _register( + "index", + "auto-reindex", + bool, + True, + project_scopable=False, + env_var="CLEVERAGENTS_AUTO_REINDEX", + description="Auto re-index on file changes.", + ) + + # ── context.* (43 keys, all project-scopable) ─────────────────────── + _ctx = "context" + _ps = True # all context keys are project-scopable + + _register( + _ctx, + "hot.max-tokens", + int, + 16000, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_HOT_TOKENS", + description="Max token budget for hot context.", + ) + _register( + _ctx, + "warm.max-decisions", int, 100, - description="Maximum context files per session.", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_WARM_DECISIONS", + description="Max recent decisions in warm context.", ) _register( - "context", - "max_tokens", + _ctx, + "cold.max-decisions", int, - 128000, - description="Maximum context token window.", + 500, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_COLD_DECISIONS", + description="Max historical decisions in cold context.", ) _register( - "context", - "auto_include_gitignore", + _ctx, + "query.limit", + int, + 20, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_QUERY_LIMIT", + description="Max retrieval results per context query.", + ) + _register( + _ctx, + "query.min-relevance", + float, + 0.3, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_QUERY_MIN_RELEVANCE", + description="Minimum relevance score (0.0-1.0) for results.", + ) + _register( + _ctx, + "file.max-size", + int, + 1048576, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_MAX_FILE_SIZE", + description="Max file size (bytes) for context inclusion.", + ) + _register( + _ctx, + "file.max-total-size", + int, + 52428800, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_MAX_TOTAL_SIZE", + description="Max total file size (bytes) across all context files.", + ) + _register( + _ctx, + "summarize.enabled", bool, True, - description="Auto-include gitignore patterns in context filtering.", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_SUMMARIZE", + description="Summarize large context segments instead of truncating.", + ) + _register( + _ctx, + "summarize.max-tokens", + int, + 1000, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_SUMMARY_TOKENS", + description="Max tokens in generated context summaries.", + ) + _register( + _ctx, + "summarize.model", + str, + None, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_SUMMARY_MODEL", + description="Model for summarization (None = actor's model).", + ) + _register( + _ctx, + "strategies.enabled", + str, + "simple-keyword,semantic-embedding,breadth-depth-navigator", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_STRATEGIES", + description="Comma-separated ACMS context strategies.", + ) + _register( + _ctx, + "strategies.arce.model", + str, + None, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_ARCE_MODEL", + description="Model for ARCE strategy reasoning loop.", + ) + _register( + _ctx, + "strategies.arce.max-rounds", + int, + 3, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_ARCE_ROUNDS", + description="Max search-refine rounds for ARCE strategy.", + ) + _register( + _ctx, + "strategies.breadth-depth-navigator.max-hops", + int, + 4, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_BDN_HOPS", + description="Max graph traversal depth for BDN strategy.", + ) + _register( + _ctx, + "budget.response-reserve-tokens", + int, + 4096, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_RESPONSE_RESERVE", + description="Tokens reserved from context window for response.", + ) + _register( + _ctx, + "budget.tool-definition-estimate", + int, + None, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_TOOL_ESTIMATE", + description="Estimated tokens for tool definitions (None = auto).", + ) + _register( + _ctx, + "budget.skeleton-ratio", + float, + 0.15, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_SKELETON_RATIO", + description="Fraction of budget for inherited plan skeleton.", + ) + _register( + _ctx, + "budget.refresh-threshold", + float, + 0.30, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_REFRESH_THRESHOLD", + description="Budget change fraction triggering re-assembly.", + ) + _register( + _ctx, + "budget.min-useful-budget", + int, + 500, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_MIN_BUDGET", + description="Min useful context budget in tokens.", + ) + _register( + _ctx, + "tiers.warm.retention-hours", + int, + 24, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_WARM_HOURS", + description="Hours before warm-tier demotion to cold.", + ) + _register( + _ctx, + "tiers.cold.retention-days", + int, + 90, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_COLD_DAYS", + description="Days before cold-tier expiry.", + ) + _register( + _ctx, + "uko.default-analyzers", + str, + "python,typescript,rust,java,markdown,json-schema", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_UKO_ANALYZERS", + description="Comma-separated default UKO analyzers.", + ) + _register( + _ctx, + "pipeline.strategy-selector", + str, + "builtin:ConfidenceWeightedSelector", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SELECTOR", + description="StrategySelector implementation class.", + ) + _register( + _ctx, + "pipeline.budget-allocator", + str, + "builtin:ProportionalBudgetAllocator", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_ALLOCATOR", + description="BudgetAllocator implementation class.", + ) + _register( + _ctx, + "pipeline.strategy-executor", + str, + "builtin:ParallelStrategyExecutor", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_EXECUTOR", + description="StrategyExecutor implementation class.", + ) + _register( + _ctx, + "pipeline.fragment-deduplicator", + str, + "builtin:ContentHashDeduplicator", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_DEDUP", + description="FragmentDeduplicator implementation class.", + ) + _register( + _ctx, + "pipeline.detail-depth-resolver", + str, + "builtin:MaxDepthResolver", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_DEPTH", + description="DetailDepthResolver implementation class.", + ) + _register( + _ctx, + "pipeline.fragment-scorer", + str, + "builtin:WeightedCompositeScorer", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SCORER", + description="FragmentScorer implementation class.", + ) + _register( + _ctx, + "pipeline.budget-packer", + str, + "builtin:GreedyKnapsackPacker", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PACKER", + description="BudgetPacker implementation class.", + ) + _register( + _ctx, + "pipeline.fragment-orderer", + str, + "builtin:RelevanceCoherenceOrderer", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_ORDERER", + description="FragmentOrderer implementation class.", + ) + _register( + _ctx, + "pipeline.preamble-generator", + str, + "builtin:ProvenancePreambleGenerator", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PREAMBLE", + description="PreambleGenerator implementation class.", + ) + _register( + _ctx, + "pipeline.skeleton-compressor", + str, + "builtin:DepthReductionCompressor", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_COMPRESSOR", + description="SkeletonCompressor implementation class.", + ) + _register( + _ctx, + "pipeline.strategy-executor.timeout-seconds", + int, + 30, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_EXEC_TIMEOUT", + description="Timeout (s) for each strategy execution.", + ) + _register( + _ctx, + "pipeline.strategy-executor.max-workers", + int, + 4, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_EXEC_WORKERS", + description="Max parallel workers for strategy execution.", + ) + _register( + _ctx, + "pipeline.strategy-executor.circuit-breaker-threshold", + int, + 3, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_EXEC_CB", + description="Failures before circuit breaker opens.", + ) + _register( + _ctx, + "pipeline.fragment-scorer.relevance-weight", + float, + 0.4, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SCORER_REL", + description="Relevance weight in composite fragment scoring.", + ) + _register( + _ctx, + "pipeline.fragment-scorer.hierarchy-weight", + float, + 0.3, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SCORER_HIER", + description="Hierarchy weight in composite fragment scoring.", + ) + _register( + _ctx, + "pipeline.fragment-scorer.quality-weight", + float, + 0.2, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SCORER_QUAL", + description="Quality weight in composite fragment scoring.", + ) + _register( + _ctx, + "pipeline.fragment-scorer.recency-weight", + float, + 0.1, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_SCORER_REC", + description="Recency weight in composite fragment scoring.", + ) + _register( + _ctx, + "pipeline.budget-packer.depth-fallback-steps", + str, + "9,4,2,0", + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PACKER_STEPS", + description="Comma-separated depth fallback steps.", + ) + _register( + _ctx, + "pipeline.budget-packer.min-fragment-tokens", + int, + 10, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PACKER_MIN", + description="Min token size for fragment inclusion.", + ) + _register( + _ctx, + "pipeline.preamble-generator.enabled", + bool, + True, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PREAMBLE_ON", + description="Whether to produce a context preamble.", + ) + _register( + _ctx, + "pipeline.preamble-generator.max-tokens", + int, + 200, + project_scopable=_ps, + env_var="CLEVERAGENTS_CTX_PIPELINE_PREAMBLE_TOK", + description="Max tokens for the context preamble.", ) - # -- skills.* ------------------------------------------------------------- + # ── provider.* (11 keys, custom env vars) ─────────────────────────── + _register( + "provider", + "openai.api-key", + str, + None, + project_scopable=False, + env_var="OPENAI_API_KEY", + description="OpenAI API key.", + ) + _register( + "provider", + "openai.org-id", + str, + None, + project_scopable=False, + env_var="OPENAI_ORG_ID", + description="OpenAI organization ID.", + ) + _register( + "provider", + "openai.base-url", + str, + None, + project_scopable=False, + env_var="OPENAI_BASE_URL", + description="Custom base URL for OpenAI-compatible APIs.", + ) + _register( + "provider", + "anthropic.api-key", + str, + None, + project_scopable=False, + env_var="ANTHROPIC_API_KEY", + description="Anthropic API key.", + ) + _register( + "provider", + "google.api-key", + str, + None, + project_scopable=False, + env_var="GOOGLE_API_KEY", + description="Google AI API key.", + ) + _register( + "provider", + "azure.endpoint", + str, + None, + project_scopable=False, + env_var="AZURE_OPENAI_ENDPOINT", + description="Azure OpenAI endpoint URL.", + ) + _register( + "provider", + "azure.api-key", + str, + None, + project_scopable=False, + env_var="AZURE_OPENAI_API_KEY", + description="Azure OpenAI API key.", + ) + _register( + "provider", + "azure.api-version", + str, + "2024-02-01", + project_scopable=False, + env_var="AZURE_OPENAI_API_VERSION", + description="Azure OpenAI API version.", + ) + _register( + "provider", + "google.gemini-api-key", + str, + None, + project_scopable=False, + env_var="GEMINI_API_KEY", + description="Google Gemini API key.", + ) + _register( + "provider", + "huggingface.token", + str, + None, + project_scopable=False, + env_var="HF_TOKEN", + description="Hugging Face access token.", + ) + _register( + "provider", + "openrouter.api-key", + str, + None, + project_scopable=False, + env_var="OPENROUTER_API_KEY", + description="OpenRouter API key.", + ) + + # ── skills.* (1 key) ──────────────────────────────────────────────── _register( "skills", "agent_skills_paths", str, - str(Path.home() / ".cleveragents" / "agent_skills"), - description=( - "Comma-separated list of directories to scan for Agent Skills " - "Standard folders (each containing a SKILL.md)." - ), - ) - - # -- index.* -------------------------------------------------------------- - _register( - "index", - "enabled", - bool, - False, - description="Enable vector-store indexing.", - ) - _register( - "index", - "backend", - str, - "faiss", - description="Vector store backend name.", - ) - _register( - "index", - "embedding_model", - str, - "fake", - description="Embedding model for vector indexing.", - ) - _register( - "index", - "embedding_dimension", - int, - 1536, - description="Embedding vector dimension.", + "", + project_scopable=True, + env_var="CLEVERAGENTS_SKILLS_AGENT_SKILLS_PATHS", + description=("Comma-separated filesystem paths for Agent Skills discovery."), ) diff --git a/src/cleveragents/cli/commands/config.py b/src/cleveragents/cli/commands/config.py index 3e33c8f6f..b5dd3191f 100644 --- a/src/cleveragents/cli/commands/config.py +++ b/src/cleveragents/cli/commands/config.py @@ -9,32 +9,34 @@ persisted in a TOML file at ``~/.cleveragents/config.toml``. |---------------------------------|-----------------------------------------| | ``agents config set `` | Set a configuration value | | ``agents config get `` | 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, } ) -- 2.52.0