diff --git a/benchmarks/config_project_scope_bench.py b/benchmarks/config_project_scope_bench.py new file mode 100644 index 000000000..fbc015f16 --- /dev/null +++ b/benchmarks/config_project_scope_bench.py @@ -0,0 +1,100 @@ +"""ASV benchmarks for project-scoped config resolution overhead. + +Measures the performance impact of project-scoped resolution compared +to global-only resolution, including: +- Resolution with project scope active +- Resolution with project scope inactive (no project name) +- get_project_overrides() lookup +- set_project_value() write +""" + +from __future__ import annotations + +import importlib +import shutil +import sys +import tempfile +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.application.services.config_service import ( # noqa: E402 + ConfigService, +) + + +class ProjectScopeResolutionTimeSuite: + """Benchmark timing for project-scoped config resolution.""" + + def setup(self) -> None: + self._tmpdir = Path(tempfile.mkdtemp()) + self._service = ConfigService( + config_dir=self._tmpdir, + config_path=self._tmpdir / "config.toml", + ) + # Pre-populate some project-scoped values + self._service.set_project_value( + "bench-project", "core.automation-profile", "trusted" + ) + self._service.set_project_value("bench-project", "plan.concurrency", "16") + + def teardown(self) -> None: + shutil.rmtree(str(self._tmpdir), ignore_errors=True) + + def time_resolve_with_project_scope(self) -> None: + """Resolve a key with project scope active (project override exists).""" + self._service.resolve("core.automation-profile", project_name="bench-project") + + def time_resolve_without_project_scope(self) -> None: + """Resolve a key without project scope (baseline).""" + self._service.resolve("core.automation-profile") + + def time_resolve_project_scope_no_override(self) -> None: + """Resolve a key with project scope but no override for that key.""" + self._service.resolve("core.log.level", project_name="bench-project") + + def time_get_project_overrides(self) -> None: + """Look up all overrides for a project.""" + self._service.get_project_overrides("bench-project") + + def time_set_project_value(self) -> None: + """Write a project-scoped value.""" + self._service.set_project_value("bench-project", "plan.concurrency", "32") + + def time_resolve_all_with_project(self) -> None: + """Resolve all keys with a project name active.""" + self._service.resolve_all(project_name="bench-project") + + +class ProjectScopeResolutionMemSuite: + """Benchmark memory consumption for project-scoped operations.""" + + 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_project_value( + "bench-project", "core.automation-profile", "trusted" + ) + self._service.set_project_value("bench-project", "plan.concurrency", "16") + + def teardown(self) -> None: + shutil.rmtree(str(self._tmpdir), ignore_errors=True) + + def mem_resolve_all_with_project(self) -> dict: + """Memory for resolving all keys with project scope.""" + return self._service.resolve_all(project_name="bench-project") + + def mem_get_project_overrides(self) -> dict: + """Memory for get_project_overrides.""" + return self._service.get_project_overrides("bench-project") diff --git a/docs/reference/config_resolution.md b/docs/reference/config_resolution.md index cb7c71c44..ec2067573 100644 --- a/docs/reference/config_resolution.md +++ b/docs/reference/config_resolution.md @@ -69,16 +69,55 @@ checks this table at priority 3 before falling back to the global value. **Partially project-scopable groups:** -- `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` +- `core.*` — `automation-profile` +- `plan.*` — `concurrency`, `max-child-depth`, `budget.per-plan` +- `sandbox.*` — `strategy`, `checkpoint.enabled` +- `skills.*` — `agent_skills_paths` **Never project-scopable:** - `server.*` — applies globally to the running process - `provider.*` — credentials are always global +- `actor.*` — actor defaults are global +- `index.*` — index backends are global + +### Setting project overrides + +Use the `--project` flag on `config set` to store a value under the +project-scoped TOML table: + +```bash +agents config set core.automation-profile manual --project my-project +agents config set plan.concurrency 16 --project my-project +``` + +### Reading project-scoped values + +When `--project` is provided, the resolver checks the project table at +priority 3 between environment variables and the global config file: + +```bash +agents config get core.automation-profile --project my-project +``` + +### Listing project overrides only + +`config list --project ` shows **only** the keys that have been +explicitly overridden for that project — not the full resolved view: + +```bash +agents config list --project my-project +``` + +### Non-scopable key rejection + +Attempting to set a non-project-scopable key (e.g. `core.data-dir`) via +`--project` raises an error: + +```bash +$ agents config set core.data-dir /tmp --project my-project +Error: Key 'core.data-dir' is not project-scopable. +``` ## CLI Commands diff --git a/features/config_project_scope.feature b/features/config_project_scope.feature new file mode 100644 index 000000000..47ab781c9 --- /dev/null +++ b/features/config_project_scope.feature @@ -0,0 +1,69 @@ +Feature: Project-scoped configuration overrides + As a user managing multiple projects + I want to set config overrides per project + So that different projects can have different settings + + Background: + Given a clean project-scoped config environment + + Scenario: Set a project-scoped config value + When I set config "core.automation-profile" to "manual" for project "my-project" + Then the project-scoped config for "my-project" should contain "core.automation-profile" = "manual" + + Scenario: Project-scoped value overrides global + Given a global config value "core.automation-profile" = "supervised" + And a project-scoped config value "core.automation-profile" = "manual" for project "my-project" + When I get config "core.automation-profile" for project "my-project" + Then the project-resolved value should be "manual" + + Scenario: Global value used when no project override exists + Given a global config value "core.automation-profile" = "supervised" + When I get config "core.automation-profile" for project "my-project" + Then the project-resolved value should be "supervised" + + Scenario: List project-scoped overrides + Given a project-scoped config value "core.automation-profile" = "manual" for project "my-project" + And a project-scoped config value "plan.concurrency" = "8" for project "my-project" + When I list config for project "my-project" + Then the output should show 2 project-scoped overrides + + Scenario: Non-project-scopable key is rejected for project set + When I attempt to set non-scopable key "core.data-dir" to "/tmp" for project "my-project" + Then the project set should fail with a scopable error + + Scenario: Project set via CLI command + When I run config set "core.automation-profile" "trusted" with project "my-project" via CLI + Then the CLI config set should succeed + And the CLI output should mention project scope "my-project" + + Scenario: Project get via CLI command + Given a project-scoped config value "core.automation-profile" = "trusted" for project "my-project" + When I run config get "core.automation-profile" with project "my-project" via CLI + Then the CLI config get should succeed + + Scenario: Project list via CLI command shows only overrides + Given a project-scoped config value "core.automation-profile" = "trusted" for project "cli-proj" + And a project-scoped config value "plan.concurrency" = "16" for project "cli-proj" + When I run config list with project "cli-proj" via CLI + Then the CLI config list should succeed + And the CLI list output should show exactly 2 entries + + Scenario: Project list via CLI command with no overrides + When I run config list with project "empty-proj" via CLI + Then the CLI config list should show no overrides message + + Scenario: Empty project name is rejected by service + When I attempt to get overrides for empty project name + Then the empty project name should raise ValueError + + Scenario: Set project value via service method + When I use service set_project_value for "my-project" key "plan.concurrency" value "12" + Then the project-scoped config for "my-project" should contain "plan.concurrency" = "12" + + Scenario: Get project overrides returns only project keys + Given a project-scoped config value "core.automation-profile" = "trusted" for project "proj-a" + And a project-scoped config value "plan.concurrency" = "6" for project "proj-a" + And a global config value "core.log.level" = "DEBUG" + When I get all project overrides for "proj-a" + Then the project overrides should have 2 keys + And the project overrides should not contain "core.log.level" diff --git a/features/steps/config_project_scope_steps.py b/features/steps/config_project_scope_steps.py new file mode 100644 index 000000000..5554c6601 --- /dev/null +++ b/features/steps/config_project_scope_steps.py @@ -0,0 +1,312 @@ +"""Step definitions for config_project_scope.feature.""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.application.services.config_service import ConfigService +from cleveragents.cli.commands import config as config_mod +from cleveragents.cli.commands.config import app as config_app + +_runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_service(context: Context) -> ConfigService: + """Return the test-scoped ConfigService instance.""" + svc: ConfigService = context._ps_config_service + return svc + + +def _cleanup(context: Context) -> None: + """Clean up temp dir.""" + tmpdir: str | None = getattr(context, "_ps_config_tmpdir", None) + if tmpdir and os.path.isdir(tmpdir): + shutil.rmtree(tmpdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a clean project-scoped config environment") +def step_clean_project_scoped_env(context: Context) -> None: + """Set up an isolated temp config directory for project-scoped tests.""" + tmpdir = tempfile.mkdtemp() + context._ps_config_tmpdir = tmpdir + tmp_path = Path(tmpdir) + context._ps_config_service = ConfigService( + config_dir=tmp_path, + config_path=tmp_path / "config.toml", + ) + context._ps_config_dir = tmp_path + context._ps_config_path = tmp_path / "config.toml" + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(lambda: _cleanup(context)) + + +# --------------------------------------------------------------------------- +# Given +# --------------------------------------------------------------------------- + + +@given('a global config value "{key}" = "{value}"') +def step_set_global_config(context: Context, key: str, value: str) -> None: + svc = _get_service(context) + data = svc.read_config() + data[key] = value + svc.write_config(data) + + +@given('a project-scoped config value "{key}" = "{value}" for project "{project}"') +def step_set_project_config( + context: Context, key: str, value: str, project: str +) -> None: + svc = _get_service(context) + data = svc.read_config() + proj_section: dict[str, Any] = data.get("project", {}) + if not isinstance(proj_section, dict): + proj_section = {} + proj_overrides: dict[str, Any] = proj_section.get(project, {}) + if not isinstance(proj_overrides, dict): + proj_overrides = {} + proj_overrides[key] = value + proj_section[project] = proj_overrides + data["project"] = proj_section + svc.write_config(data) + + +# --------------------------------------------------------------------------- +# When +# --------------------------------------------------------------------------- + + +@when('I set config "{key}" to "{value}" for project "{project}"') +def step_set_config_for_project( + context: Context, key: str, value: str, project: str +) -> None: + svc = _get_service(context) + data = svc.read_config() + proj_section: dict[str, Any] = data.get("project", {}) + if not isinstance(proj_section, dict): + proj_section = {} + proj_overrides: dict[str, Any] = proj_section.get(project, {}) + if not isinstance(proj_overrides, dict): + proj_overrides = {} + proj_overrides[key] = value + proj_section[project] = proj_overrides + data["project"] = proj_section + svc.write_config(data) + + +@when('I get config "{key}" for project "{project}"') +def step_get_config_for_project(context: Context, key: str, project: str) -> None: + svc = _get_service(context) + context.resolved = svc.resolve(key, project_name=project) + + +@when('I list config for project "{project}"') +def step_list_config_for_project(context: Context, project: str) -> None: + svc = _get_service(context) + context.project_overrides = svc.get_project_overrides(project) + + +@when('I attempt to set non-scopable key "{key}" to "{value}" for project "{project}"') +def step_attempt_set_non_scopable( + context: Context, key: str, value: str, project: str +) -> None: + svc = _get_service(context) + try: + svc.set_project_value(project, key, value) + context.raised_error = None + except (ValueError, TypeError) as exc: + context.raised_error = exc + + +@when('I run config set "{key}" "{value}" with project "{project}" via CLI') +def step_run_cli_set_with_project( + context: Context, key: str, value: str, project: str +) -> None: + tmp_dir: Path = context._ps_config_dir + tmp_path: Path = context._ps_config_path + with ( + patch.object(config_mod, "_CONFIG_DIR", tmp_dir), + patch.object(config_mod, "_CONFIG_PATH", tmp_path), + ): + context.result = _runner.invoke( + config_app, ["set", key, value, "--project", project] + ) + + +@when('I run config get "{key}" with project "{project}" via CLI') +def step_run_cli_get_with_project(context: Context, key: str, project: str) -> None: + tmp_dir: Path = context._ps_config_dir + tmp_path: Path = context._ps_config_path + with ( + patch.object(config_mod, "_CONFIG_DIR", tmp_dir), + patch.object(config_mod, "_CONFIG_PATH", tmp_path), + ): + context.result = _runner.invoke( + config_app, ["get", key, "--project", project, "--format", "json"] + ) + + +@when('I run config list with project "{project}" via CLI') +def step_run_cli_list_with_project(context: Context, project: str) -> None: + tmp_dir: Path = context._ps_config_dir + tmp_path: Path = context._ps_config_path + with ( + patch.object(config_mod, "_CONFIG_DIR", tmp_dir), + patch.object(config_mod, "_CONFIG_PATH", tmp_path), + ): + context.result = _runner.invoke( + config_app, ["list", "--project", project, "--format", "json"] + ) + + +@when("I attempt to get overrides for empty project name") +def step_attempt_empty_project_name(context: Context) -> None: + svc = _get_service(context) + try: + svc.get_project_overrides("") + context.raised_error = None + except ValueError as exc: + context.raised_error = exc + + +@when('I use service set_project_value for "{project}" key "{key}" value "{value}"') +def step_use_service_set_project_value( + context: Context, project: str, key: str, value: str +) -> None: + svc = _get_service(context) + svc.set_project_value(project, key, value) + + +@when('I get all project overrides for "{project}"') +def step_get_all_project_overrides(context: Context, project: str) -> None: + svc = _get_service(context) + context.project_overrides = svc.get_project_overrides(project) + + +# --------------------------------------------------------------------------- +# Then +# --------------------------------------------------------------------------- + + +@then('the project-scoped config for "{project}" should contain "{key}" = "{value}"') +def step_project_config_contains( + context: Context, project: str, key: str, value: str +) -> None: + svc = _get_service(context) + overrides = svc.get_project_overrides(project) + assert key in overrides, ( + f"Expected key '{key}' in project overrides, got: {list(overrides.keys())}" + ) + assert str(overrides[key]) == value, f"Expected '{value}', got '{overrides[key]}'" + + +@then('the project-resolved value should be "{value}"') +def step_project_resolved_value(context: Context, value: str) -> None: + assert str(context.resolved.value) == value, ( + f"Expected '{value}', got '{context.resolved.value}'" + ) + + +@then("the output should show {count:d} project-scoped overrides") +def step_project_override_count(context: Context, count: int) -> None: + overrides: dict[str, Any] = context.project_overrides + assert len(overrides) == count, ( + f"Expected {count} overrides, got {len(overrides)}: {list(overrides.keys())}" + ) + + +@then("the project set should fail with a scopable error") +def step_project_set_fail_scopable(context: Context) -> None: + assert context.raised_error is not None, "Expected ValueError but none was raised" + assert "not project-scopable" in str(context.raised_error), ( + f"Expected 'not project-scopable' in: {context.raised_error}" + ) + + +@then("the CLI config set should succeed") +def step_cli_set_succeed(context: Context) -> None: + assert context.result.exit_code == 0, ( + f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" + ) + + +@then('the CLI output should mention project scope "{project}"') +def step_cli_output_mentions_project(context: Context, project: str) -> None: + output: str = context.result.output + assert project in output, f"Expected '{project}' in output: {output}" + + +@then("the CLI config get should succeed") +def step_cli_get_succeed(context: Context) -> None: + assert context.result.exit_code == 0, ( + f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" + ) + + +@then("the CLI config list should succeed") +def step_cli_list_succeed(context: Context) -> None: + assert context.result.exit_code == 0, ( + f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" + ) + + +@then("the CLI list output should show exactly {count:d} entries") +def step_cli_list_exact_count(context: Context, count: int) -> None: + output: str = context.result.output + data: list[dict[str, Any]] = json.loads(output) + assert len(data) == count, f"Expected {count} entries, got {len(data)}: {data}" + + +@then("the CLI config list should show no overrides message") +def step_cli_list_no_overrides(context: Context) -> None: + output: str = context.result.output + # When no overrides and using JSON format, an empty or message output + # The command should still succeed (exit 0) but indicate nothing found + assert context.result.exit_code == 0 or "No project-scoped overrides" in output, ( + f"Expected no-overrides output, got: {output}" + ) + + +@then("the empty project name should raise ValueError") +def step_empty_project_raises(context: Context) -> None: + assert context.raised_error is not None, "Expected ValueError but none was raised" + assert isinstance(context.raised_error, ValueError), ( + f"Expected ValueError, got {type(context.raised_error).__name__}" + ) + + +@then("the project overrides should have {count:d} keys") +def step_project_overrides_key_count(context: Context, count: int) -> None: + overrides: dict[str, Any] = context.project_overrides + assert len(overrides) == count, ( + f"Expected {count} keys, got {len(overrides)}: {list(overrides.keys())}" + ) + + +@then('the project overrides should not contain "{key}"') +def step_project_overrides_not_contain(context: Context, key: str) -> None: + overrides: dict[str, Any] = context.project_overrides + assert key not in overrides, ( + f"Expected '{key}' NOT in project overrides, but it was present" + ) diff --git a/robot/config_project_scope.robot b/robot/config_project_scope.robot new file mode 100644 index 000000000..ba76f529b --- /dev/null +++ b/robot/config_project_scope.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Smoke tests for project-scoped config overrides +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_config_project_scope.py + +*** Test Cases *** +Project Scoped Set And Get Roundtrip + [Documentation] Verify that setting a project-scoped value and reading it back works + ${result}= Run Process ${PYTHON} ${HELPER} set-get-roundtrip cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} config-project-scope-set-get-ok + +Project Scoped Value Overrides Global + [Documentation] Verify that project-scoped value overrides the global value in resolution + ${result}= Run Process ${PYTHON} ${HELPER} project-overrides-global cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} config-project-overrides-global-ok + +Project Scoped List Overrides Only + [Documentation] Verify that get_project_overrides returns only project-scoped keys + ${result}= Run Process ${PYTHON} ${HELPER} list-overrides-only cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} config-project-list-overrides-ok + +Project Scoped CLI Set Get Roundtrip + [Documentation] Verify the CLI --project flag works for set and get commands + ${result}= Run Process ${PYTHON} ${HELPER} cli-roundtrip cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} config-project-cli-roundtrip-ok + +Non Scopable Key Rejected + [Documentation] Verify that a non-project-scopable key is rejected + ${result}= Run Process ${PYTHON} ${HELPER} non-scopable-rejected cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} config-project-non-scopable-ok diff --git a/robot/helper_config_project_scope.py b/robot/helper_config_project_scope.py new file mode 100644 index 000000000..bd4120974 --- /dev/null +++ b/robot/helper_config_project_scope.py @@ -0,0 +1,200 @@ +"""Helper script for config_project_scope.robot smoke tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import json +import shutil +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest.mock import patch + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from typer.testing import CliRunner # noqa: E402 + +from cleveragents.application.services.config_service import ( # noqa: E402 + ConfigLevel, + ConfigService, +) +from cleveragents.cli.commands import config as config_mod # noqa: E402 +from cleveragents.cli.commands.config import app as config_app # noqa: E402 + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_service() -> tuple[ConfigService, Path]: + """Create a ConfigService backed by an isolated temp directory.""" + tmpdir = Path(tempfile.mkdtemp()) + svc = ConfigService(config_dir=tmpdir, config_path=tmpdir / "config.toml") + return svc, tmpdir + + +def _cleanup(tmpdir: Path) -> None: + """Remove the temp directory ignoring errors.""" + shutil.rmtree(str(tmpdir), ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def set_get_roundtrip() -> None: + """Verify project-scoped set followed by get returns the value.""" + svc, tmpdir = _make_service() + try: + svc.set_project_value("my-project", "core.automation-profile", "manual") + result = svc.resolve("core.automation-profile", project_name="my-project") + if result.value == "manual" and result.source == ConfigLevel.PROJECT: + print("config-project-scope-set-get-ok") + else: + print( + f"FAIL: expected manual/project, got {result.value}/{result.source}", + file=sys.stderr, + ) + sys.exit(1) + finally: + _cleanup(tmpdir) + + +def project_overrides_global() -> None: + """Verify that project-scoped value overrides global in resolution.""" + svc, tmpdir = _make_service() + try: + svc.set_value("core.automation-profile", "supervised") + svc.set_project_value("my-project", "core.automation-profile", "full-auto") + result = svc.resolve("core.automation-profile", project_name="my-project") + if result.value == "full-auto" and result.source == ConfigLevel.PROJECT: + print("config-project-overrides-global-ok") + else: + print( + f"FAIL: expected full-auto/project, got {result.value}/{result.source}", + file=sys.stderr, + ) + sys.exit(1) + finally: + _cleanup(tmpdir) + + +def list_overrides_only() -> None: + """Verify get_project_overrides returns only project-scoped keys.""" + svc, tmpdir = _make_service() + try: + svc.set_value("core.log.level", "DEBUG") + svc.set_project_value("proj-a", "core.automation-profile", "trusted") + svc.set_project_value("proj-a", "plan.concurrency", "8") + overrides = svc.get_project_overrides("proj-a") + if len(overrides) == 2 and "core.log.level" not in overrides: + print("config-project-list-overrides-ok") + else: + print( + f"FAIL: expected 2 keys without core.log.level, got {overrides}", + file=sys.stderr, + ) + sys.exit(1) + finally: + _cleanup(tmpdir) + + +def cli_roundtrip() -> None: + """Verify CLI --project flag for set and get.""" + tmpdir = Path(tempfile.mkdtemp()) + tmppath = tmpdir / "config.toml" + try: + with ( + patch.object(config_mod, "_CONFIG_DIR", tmpdir), + patch.object(config_mod, "_CONFIG_PATH", tmppath), + ): + set_result = runner.invoke( + config_app, + ["set", "core.automation-profile", "manual", "--project", "cli-proj"], + ) + 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", + "core.automation-profile", + "--project", + "cli-proj", + "--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: dict[str, Any] = json.loads(get_result.output) + if data.get("source") == "project": + print("config-project-cli-roundtrip-ok") + else: + print( + f"FAIL: unexpected source {data.get('source')}", + file=sys.stderr, + ) + sys.exit(1) + finally: + _cleanup(tmpdir) + + +def non_scopable_rejected() -> None: + """Verify that a non-project-scopable key is rejected.""" + svc, tmpdir = _make_service() + try: + try: + svc.set_project_value("proj", "core.data-dir", "/tmp/bad") + print("FAIL: expected ValueError", file=sys.stderr) + sys.exit(1) + except ValueError as exc: + if "not project-scopable" in str(exc): + print("config-project-non-scopable-ok") + else: + print(f"FAIL: unexpected error: {exc}", file=sys.stderr) + sys.exit(1) + finally: + _cleanup(tmpdir) + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +_COMMANDS: dict[str, Callable[[], None]] = { + "set-get-roundtrip": set_get_roundtrip, + "project-overrides-global": project_overrides_global, + "list-overrides-only": list_overrides_only, + "cli-roundtrip": cli_roundtrip, + "non-scopable-rejected": non_scopable_rejected, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + _COMMANDS[sys.argv[1]]() diff --git a/src/cleveragents/application/services/config_service.py b/src/cleveragents/application/services/config_service.py index 74cd1a818..c13fae1d2 100644 --- a/src/cleveragents/application/services/config_service.py +++ b/src/cleveragents/application/services/config_service.py @@ -1369,6 +1369,90 @@ class ConfigService: raise ValueError(msg) return entry.env_var + # -- project-scoped helpers ----------------------------------------------- + + def set_project_value( + self, + project_name: str, + key: str, + value: Any, + ) -> None: + """Persist a single key-value pair under a project-scoped TOML table. + + The value is stored at ``[project.""].`` in the + global config file. + + Parameters + ---------- + project_name: + Name of the project. + key: + Dotted config key. + value: + Value to store (must match the registered type). + + Raises + ------ + ValueError + If *key* is not a registered configuration key. + ValueError + If *project_name* is empty. + TypeError + If *value* does not match the key's registered type. + """ + if not project_name: + msg = "project_name must not be empty." + raise ValueError(msg) + entry = self.validate_key(key) + if not entry.project_scopable: + msg = f"Key '{key}' is not project-scopable." + raise ValueError(msg) + coerced: Any = self.validate_type(key, value) + + data = self.read_config() + project_section: dict[str, Any] = data.get("project", {}) + if not isinstance(project_section, dict): + project_section = {} + proj_overrides: dict[str, Any] = project_section.get(project_name, {}) + if not isinstance(proj_overrides, dict): + proj_overrides = {} + proj_overrides[key] = coerced + project_section[project_name] = proj_overrides + data["project"] = project_section + self.write_config(data) + + def get_project_overrides( + self, + project_name: str, + ) -> dict[str, Any]: + """Return all project-scoped overrides for *project_name*. + + Returns a ``{key: value}`` mapping of every key that has been + explicitly set in the ``[project.""]`` TOML table. + Keys that are not present in the project table are omitted. + + Parameters + ---------- + project_name: + Name of the project. + + Raises + ------ + ValueError + If *project_name* is empty. + """ + if not project_name: + msg = "project_name must not be empty." + raise ValueError(msg) + data = self.read_config() + project_section: dict[str, Any] = data.get("project", {}) + if not isinstance(project_section, dict): + return {} + proj_data: dict[str, Any] = project_section.get(project_name, {}) + if not isinstance(proj_data, dict): + return {} + return dict(proj_data) + # -- bulk helpers --------------------------------------------------------- def resolve_all( diff --git a/src/cleveragents/cli/commands/config.py b/src/cleveragents/cli/commands/config.py index b5dd3191f..81a89e99b 100644 --- a/src/cleveragents/cli/commands/config.py +++ b/src/cleveragents/cli/commands/config.py @@ -383,10 +383,78 @@ def config_list( raise typer.Exit(code=1) from exc svc = _get_service() + + # When --project is given, show only project-scoped overrides + if project is not None: + proj_overrides = svc.get_project_overrides(project) + settings_list: list[dict[str, Any]] = [] + for key in sorted(proj_overrides): + raw_value = proj_overrides[key] + str_value = str(raw_value) if raw_value is not None else "" + + # Key filter + if ( + key_re is not None + and not key_re.search(key) + and (pattern is None or not fnmatch.fnmatch(key, pattern)) + ): + continue + + # Value filter + if val_re is not None and not val_re.search(str_value): + continue + + # Mask secrets unless --show-secrets + display_value: Any = raw_value + if _is_secret_key(key) and not show_secrets and raw_value is not None: + display_value = _mask_value(str(raw_value)) + + settings_list.append( + { + "key": key, + "value": display_value, + "source": f"project:{project}", + "modified": True, + } + ) + + if not settings_list: + console.print( + "[yellow]No project-scoped overrides match the filter.[/yellow]" + ) + return + + if fmt != OutputFormat.RICH.value: + for entry in settings_list: + if hasattr(entry["value"], "__fspath__"): + entry["value"] = str(entry["value"]) + typer.echo(format_output(settings_list, fmt)) + return + + table = Table( + title=f"Project '{project}' overrides ({len(settings_list)} settings)" + ) + table.add_column("Key", style="cyan") + table.add_column("Value", style="white") + table.add_column("Source", style="yellow") + table.add_column("Modified", justify="center") + + for entry in settings_list: + mod_marker = "[green]yes[/green]" if entry["modified"] else "" + table.add_row( + entry["key"], + str(entry["value"]), + entry["source"], + mod_marker, + ) + + console.print(table) + return + all_resolved = svc.resolve_all(project_name=project) defaults = _settings_defaults() - settings_list: list[dict[str, Any]] = [] + settings_list_all: list[dict[str, Any]] = [] for key in sorted(_REGISTRY): # Key filter: support both glob (fnmatch) and regex if ( @@ -413,7 +481,7 @@ def config_list( if _is_secret_key(key) and not show_secrets and raw_value is not None: display_value = _mask_value(str(raw_value)) - settings_list.append( + settings_list_all.append( { "key": key, "value": display_value, @@ -422,25 +490,25 @@ def config_list( } ) - if not settings_list: + if not settings_list_all: console.print("[yellow]No configuration values match the filter.[/yellow]") return if fmt != OutputFormat.RICH.value: # Serialise Path objects for JSON/YAML - for entry in settings_list: + for entry in settings_list_all: if hasattr(entry["value"], "__fspath__"): entry["value"] = str(entry["value"]) - typer.echo(format_output(settings_list, fmt)) + typer.echo(format_output(settings_list_all, fmt)) return - table = Table(title=f"Configuration ({len(settings_list)} settings)") + table = Table(title=f"Configuration ({len(settings_list_all)} settings)") table.add_column("Key", style="cyan") table.add_column("Value", style="white") table.add_column("Source", style="yellow") table.add_column("Modified", justify="center") - for entry in settings_list: + for entry in settings_list_all: mod_marker = "[green]yes[/green]" if entry["modified"] else "" table.add_row( entry["key"],