fix(cli): add missing Origin panel, Overridden field, Winner indicator, and JSON envelope in config get output
CI / lint (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 53s
CI / security (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 35s
CI / unit_tests (pull_request) Failing after 55s
CI / docker (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / helm (pull_request) Successful in 24s
CI / integration_tests (pull_request) Has been cancelled
CI / e2e_tests (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / lint (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 53s
CI / security (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 35s
CI / unit_tests (pull_request) Failing after 55s
CI / docker (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / helm (pull_request) Successful in 24s
CI / integration_tests (pull_request) Has been cancelled
CI / e2e_tests (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
Implements all spec-required output for `agents config get`: Rich output: - Rename panel title from 'Configuration Value' to 'Config' per spec - Add 'Overridden' field to the Config panel - Add 'Origin' panel showing File, Line, and Default fields - Add 'Resolution Chain' panel (always shown, not just with --verbose) - Add 'Winner' indicator to the Resolution Chain panel - Use spec-required type strings (string/boolean/integer) instead of Python type names (str/bool/int) - Add '✓ OK Config read' confirmation message JSON output: - Wrap result in standard envelope (command, status, exit_code, data, timing, messages) - Add 'overridden' field to data - Add 'origin' nested object (file, line, default) - Add 'winner' nested object (source, level) - Use human-readable source names (CLI flag, Env var, Config file, Default) instead of internal enum values - Add 'started' timestamp to timing BDD: - Add features/config_get_spec_output.feature with 20 scenarios covering all Rich panels and JSON envelope fields - Update existing tests to match new spec-compliant output ISSUES CLOSED: #3423
This commit is contained in:
@@ -78,14 +78,14 @@ Feature: Config CLI commands
|
||||
# --- RESOLUTION CHAIN ---
|
||||
Scenario: Resolution chain shows default source
|
||||
When I run config get key "core.log.level" formatted as "json"
|
||||
Then the resolution chain should include "default" source
|
||||
Then the resolution chain should include "Default" source
|
||||
|
||||
# --- SET/GET ROUNDTRIP ---
|
||||
Scenario: Set and get roundtrip
|
||||
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 "global"
|
||||
And the get value source should be "Config file"
|
||||
|
||||
# --- FORMAT ---
|
||||
Scenario: Config list with JSON format
|
||||
|
||||
@@ -197,8 +197,8 @@ 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 "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"
|
||||
Then the safety-net get rich output should contain "Config"
|
||||
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
|
||||
@@ -283,4 +283,4 @@ Feature: Config CLI safety-net coverage
|
||||
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 "core.log.level" with format "rich" and verbose
|
||||
Then the safety-net captured console output should contain "active"
|
||||
Then the safety-net captured console output should contain "Winner"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
Feature: agents config get spec-compliant output
|
||||
As a developer
|
||||
I want `agents config get` to render all spec-required panels and JSON fields
|
||||
So that the output matches the specification exactly
|
||||
|
||||
Background:
|
||||
Given a clean config get spec test environment
|
||||
|
||||
# --- Rich output: three panels ---
|
||||
|
||||
Scenario: Rich output shows Config panel with correct title
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain panel "Config"
|
||||
|
||||
Scenario: Rich output shows Overridden field in Config panel
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain "Overridden"
|
||||
|
||||
Scenario: Rich output shows Origin panel
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain panel "Origin"
|
||||
|
||||
Scenario: Rich output shows Resolution Chain panel
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain panel "Resolution Chain"
|
||||
|
||||
Scenario: Rich output shows Winner in Resolution Chain panel
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain "Winner"
|
||||
|
||||
Scenario: Rich output uses spec type string not Python type name
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain "string"
|
||||
And the spec config get rich output should not contain "str"
|
||||
|
||||
Scenario: Rich output shows OK confirmation message
|
||||
When I run spec config get "core.log.level"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get rich output should contain "OK"
|
||||
|
||||
# --- JSON output: standard envelope ---
|
||||
|
||||
Scenario: JSON output is wrapped in standard envelope
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON envelope should have field "command" equal to "config get"
|
||||
And the spec config get JSON envelope should have field "status" equal to "ok"
|
||||
And the spec config get JSON envelope should have field "exit_code" equal to 0
|
||||
And the spec config get JSON envelope should have field "data"
|
||||
And the spec config get JSON envelope should have field "timing"
|
||||
And the spec config get JSON envelope should have field "messages"
|
||||
|
||||
Scenario: JSON data contains all required fields
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data should have field "key"
|
||||
And the spec config get JSON data should have field "value"
|
||||
And the spec config get JSON data should have field "source"
|
||||
And the spec config get JSON data should have field "overridden"
|
||||
And the spec config get JSON data should have field "type"
|
||||
And the spec config get JSON data should have field "origin"
|
||||
And the spec config get JSON data should have field "resolution_chain"
|
||||
And the spec config get JSON data should have field "winner"
|
||||
|
||||
Scenario: JSON data type field uses spec type string
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data field "type" should equal "string"
|
||||
|
||||
Scenario: JSON data overridden field is false for default value
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data field "overridden" should be false
|
||||
|
||||
Scenario: JSON data overridden field is true when value is set
|
||||
Given I have set spec config "core.log.level" to "DEBUG"
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data field "overridden" should be true
|
||||
|
||||
Scenario: JSON data origin contains default field
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON origin should have field "default"
|
||||
|
||||
Scenario: JSON data resolution chain uses human-readable source names
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON resolution chain should use human-readable source names
|
||||
|
||||
Scenario: JSON data winner contains source and level
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON winner should have field "source"
|
||||
And the spec config get JSON winner should have field "level"
|
||||
|
||||
Scenario: JSON timing contains started and duration_ms
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON timing should have field "started"
|
||||
And the spec config get JSON timing should have field "duration_ms"
|
||||
|
||||
# --- Source name mapping ---
|
||||
|
||||
Scenario: JSON source uses Config file when value from global config
|
||||
Given I have set spec config "core.log.level" to "WARNING"
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data field "source" should equal "Config file"
|
||||
|
||||
Scenario: JSON source uses Default when value from default
|
||||
When I run spec config get "core.log.level" with format "json"
|
||||
Then the spec config get should succeed
|
||||
And the spec config get JSON data field "source" should equal "Default"
|
||||
@@ -253,6 +253,7 @@ def step_cfg_boost_get_verbose_path(context: Context, key: str, fmt: str) -> Non
|
||||
chain_with_path = [
|
||||
{"source": "cli_flag", "value": None},
|
||||
{"source": "env_var", "value": None},
|
||||
{"source": "local", "value": None},
|
||||
{"source": "project", "value": None},
|
||||
{"source": "global", "value": Path("/tmp/fake/path")},
|
||||
{"source": "default", "value": Path("/default/path")},
|
||||
@@ -275,7 +276,9 @@ def step_cfg_boost_get_verbose_path(context: Context, key: str, fmt: str) -> Non
|
||||
|
||||
@then("the cfg-boost JSON resolution chain entries should have string values")
|
||||
def step_cfg_boost_json_chain_strings(context: Context) -> None:
|
||||
data = json.loads(context.cfg_boost_result.output)
|
||||
envelope = json.loads(context.cfg_boost_result.output)
|
||||
# JSON output is now wrapped in a standard envelope; resolution_chain is in data
|
||||
data = envelope.get("data", envelope)
|
||||
chain = data.get("resolution_chain", [])
|
||||
assert len(chain) > 0, "Expected non-empty resolution chain"
|
||||
for entry in chain:
|
||||
|
||||
@@ -520,20 +520,23 @@ def step_sn_get_valid_yaml(context: Context) -> None:
|
||||
|
||||
@then('the safety-net get YAML should contain key "{key}"')
|
||||
def step_sn_get_yaml_key(context: Context, key: str) -> None:
|
||||
assert key in context._sn_get_yaml, (
|
||||
f"Key '{key}' not in YAML: {list(context._sn_get_yaml.keys())}"
|
||||
)
|
||||
# YAML output is now wrapped in a standard envelope; fields are in data
|
||||
yaml_data = context._sn_get_yaml
|
||||
inner = yaml_data.get("data", yaml_data)
|
||||
assert key in inner, f"Key '{key}' not in YAML data: {list(inner.keys())}"
|
||||
|
||||
|
||||
@then("the safety-net get output should be valid JSON with type field")
|
||||
def step_sn_get_json_type(context: Context) -> None:
|
||||
result = context._sn_result
|
||||
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
|
||||
parsed = json.loads(result.output.strip())
|
||||
assert "type" in parsed, f"'type' not in JSON keys: {list(parsed.keys())}"
|
||||
assert "key" in parsed, f"'key' not in JSON keys: {list(parsed.keys())}"
|
||||
envelope = json.loads(result.output.strip())
|
||||
# JSON output is now wrapped in a standard envelope; fields are in data
|
||||
parsed = envelope.get("data", envelope)
|
||||
assert "type" in parsed, f"'type' not in JSON data keys: {list(parsed.keys())}"
|
||||
assert "key" in parsed, f"'key' not in JSON data keys: {list(parsed.keys())}"
|
||||
assert "resolution_chain" in parsed, (
|
||||
f"'resolution_chain' not in JSON: {list(parsed.keys())}"
|
||||
f"'resolution_chain' not in JSON data: {list(parsed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -143,9 +143,11 @@ def step_config_get_contains_key(context: Context, key: str) -> None:
|
||||
@then("the config get output should show the resolution chain")
|
||||
def step_config_get_resolution_chain(context: Context) -> None:
|
||||
output = context.result.output
|
||||
assert "Resolution chain" in output or "resolution_chain" in output, (
|
||||
f"Expected resolution chain in output: {output}"
|
||||
)
|
||||
assert (
|
||||
"Resolution Chain" in output
|
||||
or "Resolution chain" in output
|
||||
or "resolution_chain" in output
|
||||
), f"Expected resolution chain in output: {output}"
|
||||
|
||||
|
||||
@then("the config get should fail with unknown key error")
|
||||
|
||||
@@ -327,9 +327,11 @@ def step_get_verbose_succeed(context: Context) -> None:
|
||||
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}"
|
||||
)
|
||||
assert (
|
||||
"Resolution Chain" in output
|
||||
or "Resolution chain" in output
|
||||
or "resolution_chain" in output
|
||||
), f"Expected resolution chain in output: {output}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -353,6 +355,7 @@ def step_mock_resolve_path(context: Context, key: str) -> None:
|
||||
"value": None,
|
||||
"env_name": f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}",
|
||||
},
|
||||
{"source": ConfigLevel.LOCAL.value, "value": None},
|
||||
{"source": ConfigLevel.PROJECT.value, "value": None},
|
||||
{
|
||||
"source": ConfigLevel.GLOBAL.value,
|
||||
@@ -395,8 +398,10 @@ def step_get_result_json(context: Context) -> None:
|
||||
|
||||
@then("the config cli branch get JSON value should be a string not a Path")
|
||||
def step_get_json_value_string(context: Context) -> None:
|
||||
data = context._cfg_branch_get_json
|
||||
# The top-level "value" should be a string (serialised from Path)
|
||||
envelope = context._cfg_branch_get_json
|
||||
# JSON output is now wrapped in a standard envelope; value is in data
|
||||
data = envelope.get("data", envelope)
|
||||
# The "value" should be a string (serialised from Path)
|
||||
assert isinstance(data["value"], str), (
|
||||
f"Expected str, got {type(data['value'])}: {data['value']}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Step definitions for config_get_spec_output.feature.
|
||||
|
||||
Tests that `agents config get` output matches the specification:
|
||||
- Rich output: three panels (Config, Origin, Resolution Chain)
|
||||
- JSON output: standard envelope with all required fields
|
||||
- Spec-required type strings and human-readable source names
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
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.cli.commands import config as config_mod
|
||||
from cleveragents.cli.commands.config import app as config_app
|
||||
|
||||
_runner = CliRunner(mix_stderr=False)
|
||||
|
||||
# Human-readable source names per spec
|
||||
_SPEC_SOURCE_NAMES = {
|
||||
"CLI flag",
|
||||
"Env var",
|
||||
"Local file",
|
||||
"Project file",
|
||||
"Config file",
|
||||
"Default",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_spec_temp(context: Context) -> None:
|
||||
"""Redirect config dir/path to a fresh temp directory."""
|
||||
if not hasattr(context, "_spec_tmpdir"):
|
||||
context._spec_tmpdir = tempfile.mkdtemp(prefix="cfg_spec_")
|
||||
tmp = Path(context._spec_tmpdir)
|
||||
context._spec_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
|
||||
context._spec_path_patch = patch.object(
|
||||
config_mod, "_CONFIG_PATH", tmp / "config.toml"
|
||||
)
|
||||
context._spec_dir_patch.start()
|
||||
context._spec_path_patch.start()
|
||||
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = [] # type: ignore[var-annotated]
|
||||
context._cleanup_handlers.append(lambda: _teardown_spec_temp(context))
|
||||
|
||||
|
||||
def _teardown_spec_temp(context: Context) -> None:
|
||||
for attr in ("_spec_dir_patch", "_spec_path_patch"):
|
||||
patcher = getattr(context, attr, None)
|
||||
if patcher is not None:
|
||||
with contextlib.suppress(RuntimeError):
|
||||
patcher.stop()
|
||||
tmpdir = getattr(context, "_spec_tmpdir", None)
|
||||
if tmpdir and os.path.isdir(tmpdir):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a clean config get spec test environment")
|
||||
def step_spec_clean_env(context: Context) -> None:
|
||||
_setup_spec_temp(context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run spec config get "{key}"')
|
||||
def step_spec_run_get(context: Context, key: str) -> None:
|
||||
context.spec_result = _runner.invoke(config_app, ["get", key])
|
||||
|
||||
|
||||
@when('I run spec config get "{key}" with format "{fmt}"')
|
||||
def step_spec_run_get_fmt(context: Context, key: str, fmt: str) -> None:
|
||||
context.spec_result = _runner.invoke(config_app, ["get", key, "--format", fmt])
|
||||
|
||||
|
||||
@given('I have set spec config "{key}" to "{value}"')
|
||||
def step_spec_preset_config(context: Context, key: str, value: str) -> None:
|
||||
result = _runner.invoke(config_app, ["set", key, value])
|
||||
assert result.exit_code == 0, f"Pre-set failed: {result.output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — basic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the spec config get should succeed")
|
||||
def step_spec_get_succeed(context: Context) -> None:
|
||||
assert context.spec_result.exit_code == 0, (
|
||||
f"Expected exit 0, got {context.spec_result.exit_code}: "
|
||||
f"{context.spec_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec config get rich output should contain panel "{panel_title}"')
|
||||
def step_spec_rich_contains_panel(context: Context, panel_title: str) -> None:
|
||||
output = context.spec_result.output
|
||||
assert panel_title in output, (
|
||||
f"Expected panel '{panel_title}' in Rich output: {output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec config get rich output should contain "{text}"')
|
||||
def step_spec_rich_contains(context: Context, text: str) -> None:
|
||||
output = context.spec_result.output
|
||||
assert text in output, f"Expected '{text}' in Rich output: {output}"
|
||||
|
||||
|
||||
@then('the spec config get rich output should not contain "{text}"')
|
||||
def step_spec_rich_not_contains(context: Context, text: str) -> None:
|
||||
output = context.spec_result.output
|
||||
# Check that the text doesn't appear as a standalone type name
|
||||
# (e.g. "str" should not appear but "string" is fine)
|
||||
# We check for exact word boundary to avoid false positives
|
||||
import re
|
||||
|
||||
# Look for the text as a standalone word (not part of "string", "integer", etc.)
|
||||
pattern = rf"\b{re.escape(text)}\b"
|
||||
# Exclude lines that contain "string", "integer", "boolean" etc.
|
||||
lines = output.split("\n")
|
||||
for line in lines:
|
||||
if re.search(pattern, line):
|
||||
# Check if this is a false positive (e.g. "str" inside "string")
|
||||
# Only flag if the match is not part of a longer type word
|
||||
if text == "str" and "string" in line:
|
||||
continue
|
||||
if text == "bool" and "boolean" in line:
|
||||
continue
|
||||
if text == "int" and "integer" in line:
|
||||
continue
|
||||
raise AssertionError(
|
||||
f"Found '{text}' as standalone word in Rich output line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_spec_json(context: Context) -> dict[str, Any]:
|
||||
"""Parse and cache the JSON output."""
|
||||
if not hasattr(context, "_spec_json"):
|
||||
context._spec_json = json.loads(context.spec_result.output.strip())
|
||||
return context._spec_json
|
||||
|
||||
|
||||
@then(
|
||||
'the spec config get JSON envelope should have field "{field}" equal to "{value}"'
|
||||
)
|
||||
def step_spec_json_envelope_field_str(context: Context, field: str, value: str) -> None:
|
||||
envelope = _get_spec_json(context)
|
||||
assert field in envelope, (
|
||||
f"Field '{field}' not in envelope: {list(envelope.keys())}"
|
||||
)
|
||||
assert str(envelope[field]) == value, (
|
||||
f"Expected envelope['{field}'] == '{value}', got {envelope[field]!r}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the spec config get JSON envelope should have field "{field}" equal to {value:d}'
|
||||
)
|
||||
def step_spec_json_envelope_field_int(context: Context, field: str, value: int) -> None:
|
||||
envelope = _get_spec_json(context)
|
||||
assert field in envelope, (
|
||||
f"Field '{field}' not in envelope: {list(envelope.keys())}"
|
||||
)
|
||||
assert envelope[field] == value, (
|
||||
f"Expected envelope['{field}'] == {value}, got {envelope[field]!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec config get JSON envelope should have field "{field}"')
|
||||
def step_spec_json_envelope_has_field(context: Context, field: str) -> None:
|
||||
envelope = _get_spec_json(context)
|
||||
assert field in envelope, (
|
||||
f"Field '{field}' not in envelope: {list(envelope.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_spec_json_data(context: Context) -> dict[str, Any]:
|
||||
"""Return the data object from the JSON envelope."""
|
||||
envelope = _get_spec_json(context)
|
||||
return envelope["data"]
|
||||
|
||||
|
||||
@then('the spec config get JSON data should have field "{field}"')
|
||||
def step_spec_json_data_has_field(context: Context, field: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
assert field in data, f"Field '{field}' not in JSON data: {list(data.keys())}"
|
||||
|
||||
|
||||
@then('the spec config get JSON data field "{field}" should equal "{value}"')
|
||||
def step_spec_json_data_field_str(context: Context, field: str, value: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
assert field in data, f"Field '{field}' not in JSON data: {list(data.keys())}"
|
||||
assert data[field] == value, (
|
||||
f"Expected data['{field}'] == '{value}', got {data[field]!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec config get JSON data field "{field}" should be false')
|
||||
def step_spec_json_data_field_false(context: Context, field: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
assert field in data, f"Field '{field}' not in JSON data: {list(data.keys())}"
|
||||
assert data[field] is False, (
|
||||
f"Expected data['{field}'] == False, got {data[field]!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec config get JSON data field "{field}" should be true')
|
||||
def step_spec_json_data_field_true(context: Context, field: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
assert field in data, f"Field '{field}' not in JSON data: {list(data.keys())}"
|
||||
assert data[field] is True, f"Expected data['{field}'] == True, got {data[field]!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON origin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the spec config get JSON origin should have field "{field}"')
|
||||
def step_spec_json_origin_has_field(context: Context, field: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
origin = data.get("origin", {})
|
||||
assert field in origin, f"Field '{field}' not in JSON origin: {list(origin.keys())}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON resolution chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then(
|
||||
"the spec config get JSON resolution chain should use human-readable source names"
|
||||
)
|
||||
def step_spec_json_chain_human_readable(context: Context) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
chain = data.get("resolution_chain", [])
|
||||
assert len(chain) > 0, "Expected non-empty resolution chain"
|
||||
for entry in chain:
|
||||
src = entry.get("source", "")
|
||||
assert src in _SPEC_SOURCE_NAMES, (
|
||||
f"Source '{src}' is not a spec-required human-readable name. "
|
||||
f"Expected one of: {_SPEC_SOURCE_NAMES}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON winner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the spec config get JSON winner should have field "{field}"')
|
||||
def step_spec_json_winner_has_field(context: Context, field: str) -> None:
|
||||
data = _get_spec_json_data(context)
|
||||
winner = data.get("winner", {})
|
||||
assert field in winner, f"Field '{field}' not in JSON winner: {list(winner.keys())}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — JSON timing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the spec config get JSON timing should have field "{field}"')
|
||||
def step_spec_json_timing_has_field(context: Context, field: str) -> None:
|
||||
envelope = _get_spec_json(context)
|
||||
timing = envelope.get("timing", {})
|
||||
assert field in timing, f"Field '{field}' not in JSON timing: {list(timing.keys())}"
|
||||
@@ -25,6 +25,8 @@ from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -35,6 +37,7 @@ from rich.table import Table
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.application.services.config_service import (
|
||||
_REGISTRY,
|
||||
ConfigLevel,
|
||||
ConfigScope,
|
||||
ConfigService,
|
||||
)
|
||||
@@ -163,6 +166,124 @@ def _resolution_chain(key: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec-required display helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping from internal ConfigLevel values to spec-required human-readable names
|
||||
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
|
||||
ConfigLevel.CLI_FLAG.value: "CLI flag",
|
||||
ConfigLevel.ENV_VAR.value: "Env var",
|
||||
ConfigLevel.LOCAL.value: "Local file",
|
||||
ConfigLevel.PROJECT.value: "Project file",
|
||||
ConfigLevel.GLOBAL.value: "Config file",
|
||||
ConfigLevel.DEFAULT.value: "Default",
|
||||
}
|
||||
|
||||
# Mapping from Python type names to spec-required type strings
|
||||
_PYTHON_TYPE_TO_SPEC: dict[str, str] = {
|
||||
"str": "string",
|
||||
"bool": "boolean",
|
||||
"int": "integer",
|
||||
"float": "number",
|
||||
"NoneType": "null",
|
||||
"None": "null",
|
||||
}
|
||||
|
||||
|
||||
def _spec_type_name(value: Any) -> str:
|
||||
"""Return the spec-required type string for *value*."""
|
||||
if value is None:
|
||||
return "null"
|
||||
python_name = type(value).__name__
|
||||
return _PYTHON_TYPE_TO_SPEC.get(python_name, python_name)
|
||||
|
||||
|
||||
def _spec_source_name(internal_source: str) -> str:
|
||||
"""Return the spec-required human-readable source name."""
|
||||
return _SOURCE_DISPLAY_NAMES.get(internal_source, internal_source)
|
||||
|
||||
|
||||
def _build_spec_resolution_chain(
|
||||
chain: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert internal chain entries to spec-required format.
|
||||
|
||||
The spec requires:
|
||||
``[{"level": 1, "source": "CLI flag", "value": null}, ...]``
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for idx, entry in enumerate(chain, start=1):
|
||||
result.append(
|
||||
{
|
||||
"level": idx,
|
||||
"source": _spec_source_name(entry.get("source", "")),
|
||||
"value": entry.get("value"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _find_winner_in_chain(
|
||||
chain: list[dict[str, Any]],
|
||||
winning_source: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the winner dict ``{"source": ..., "level": ...}`` for the chain.
|
||||
|
||||
*winning_source* is the internal ``ConfigLevel.value`` string.
|
||||
"""
|
||||
spec_name = _spec_source_name(winning_source)
|
||||
for idx, entry in enumerate(chain, start=1):
|
||||
if entry.get("source") == winning_source and entry.get("value") is not None:
|
||||
return {"source": spec_name, "level": idx}
|
||||
# Fallback: last entry (default)
|
||||
return {"source": spec_name, "level": len(chain)}
|
||||
|
||||
|
||||
def _build_origin(
|
||||
chain: list[dict[str, Any]],
|
||||
winning_source: str,
|
||||
default_value: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``origin`` object for the JSON envelope.
|
||||
|
||||
The origin shows where the winning value came from (file path, line,
|
||||
and the registered default). Line numbers are not tracked by the
|
||||
service, so ``line`` is ``null`` unless the service provides it.
|
||||
"""
|
||||
file_path: str | None = None
|
||||
line: int | None = None
|
||||
|
||||
# Find the winning chain entry to extract path info
|
||||
for entry in chain:
|
||||
if entry.get("source") == winning_source and entry.get("value") is not None:
|
||||
raw_path = entry.get("path")
|
||||
if raw_path:
|
||||
file_path = str(raw_path)
|
||||
break
|
||||
|
||||
# If no file path found (e.g. env var or CLI flag), use a descriptive label
|
||||
if file_path is None:
|
||||
if winning_source == ConfigLevel.ENV_VAR.value:
|
||||
# Use the env var name from the chain entry
|
||||
for entry in chain:
|
||||
if entry.get("source") == winning_source:
|
||||
env_name = entry.get("env_name")
|
||||
if env_name:
|
||||
file_path = f"${env_name}"
|
||||
break
|
||||
elif winning_source == ConfigLevel.CLI_FLAG.value:
|
||||
file_path = "(CLI flag)"
|
||||
elif winning_source == ConfigLevel.DEFAULT.value:
|
||||
file_path = "(built-in default)"
|
||||
|
||||
return {
|
||||
"file": file_path,
|
||||
"line": line,
|
||||
"default": default_value,
|
||||
}
|
||||
|
||||
|
||||
def _read_config_file() -> dict[str, Any]:
|
||||
"""Read the TOML config file and return its contents as a flat dict."""
|
||||
svc = _get_service()
|
||||
@@ -316,55 +437,133 @@ def config_get(
|
||||
agents config get plan.budget.per-plan --verbose
|
||||
agents config get core.automation-profile --project local/prod
|
||||
"""
|
||||
started_at = datetime.now(tz=UTC)
|
||||
t0 = time.monotonic()
|
||||
|
||||
normalized = _validate_key(key)
|
||||
svc = _get_service()
|
||||
entry = _REGISTRY[normalized]
|
||||
|
||||
# Always resolve with verbose=True so we have the full chain for
|
||||
# the Origin panel and Resolution Chain panel (spec requires them always).
|
||||
resolved = svc.resolve(
|
||||
normalized,
|
||||
project_name=project,
|
||||
verbose=verbose,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"key": normalized,
|
||||
"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
|
||||
elapsed_ms = round((time.monotonic() - t0) * 1000)
|
||||
|
||||
# Determine overridden: value differs from the registered default
|
||||
overridden = resolved.value != entry.default
|
||||
|
||||
# Build spec-required resolution chain (human-readable source names)
|
||||
spec_chain = _build_spec_resolution_chain(resolved.chain)
|
||||
|
||||
# Build winner
|
||||
winner = _find_winner_in_chain(resolved.chain, resolved.source.value)
|
||||
|
||||
# Build origin
|
||||
origin = _build_origin(resolved.chain, resolved.source.value, entry.default)
|
||||
|
||||
# Spec-required type string
|
||||
spec_type = _spec_type_name(resolved.value)
|
||||
|
||||
# Spec-required source name
|
||||
spec_source = _spec_source_name(resolved.source.value)
|
||||
|
||||
# Serialise Path objects
|
||||
display_value: Any = resolved.value
|
||||
if hasattr(display_value, "__fspath__"):
|
||||
display_value = str(display_value)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
# Serialise Path objects to strings for JSON/YAML
|
||||
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))
|
||||
# Build the standard JSON envelope per spec
|
||||
data: dict[str, Any] = {
|
||||
"key": normalized,
|
||||
"value": display_value,
|
||||
"source": spec_source,
|
||||
"overridden": overridden,
|
||||
"type": spec_type,
|
||||
"origin": origin,
|
||||
"resolution_chain": spec_chain,
|
||||
"winner": winner,
|
||||
}
|
||||
# Serialise Path objects in chain values
|
||||
for chain_entry in data["resolution_chain"]:
|
||||
if hasattr(chain_entry.get("value"), "__fspath__"):
|
||||
chain_entry["value"] = str(chain_entry["value"])
|
||||
|
||||
envelope: dict[str, Any] = {
|
||||
"command": "config get",
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": data,
|
||||
"timing": {
|
||||
"started": started_at.isoformat(),
|
||||
"duration_ms": elapsed_ms,
|
||||
},
|
||||
"messages": ["Config read"],
|
||||
}
|
||||
typer.echo(format_output(envelope, fmt))
|
||||
return
|
||||
|
||||
# ── Rich output: three panels per spec ──────────────────────────────
|
||||
|
||||
# Panel 1: Config
|
||||
overridden_display = "yes" if overridden else "no"
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]Key:[/bold] {normalized}\n"
|
||||
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",
|
||||
f"[bold]Value:[/bold] {display_value}\n"
|
||||
f"[bold]Source:[/bold] {spec_source}\n"
|
||||
f"[bold]Overridden:[/bold] {overridden_display}\n"
|
||||
f"[bold]Type:[/bold] {spec_type}",
|
||||
title="Config",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
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}")
|
||||
# Panel 2: Origin
|
||||
origin_file = origin.get("file") or "(unknown)"
|
||||
origin_line = origin.get("line")
|
||||
origin_default = origin.get("default")
|
||||
origin_line_display = str(origin_line) if origin_line is not None else "(unknown)"
|
||||
origin_default_display = (
|
||||
str(origin_default) if origin_default is not None else "(none)"
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]File:[/bold] {origin_file}\n"
|
||||
f"[bold]Line:[/bold] {origin_line_display}\n"
|
||||
f"[bold]Default:[/bold] {origin_default_display}",
|
||||
title="Origin",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Panel 3: Resolution Chain (always shown per spec)
|
||||
chain_lines: list[str] = []
|
||||
for chain_entry in spec_chain:
|
||||
level = chain_entry["level"]
|
||||
src_name = chain_entry["source"]
|
||||
val = chain_entry.get("value")
|
||||
val_display = str(val) if val is not None else "(not set)"
|
||||
chain_lines.append(f"{level}. {src_name}: {val_display}")
|
||||
# Add Winner line
|
||||
winner_src = winner.get("source", "")
|
||||
winner_level = winner.get("level", "")
|
||||
chain_lines.append(f"[bold]Winner:[/bold] {winner_src} (level {winner_level})")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"\n".join(chain_lines),
|
||||
title="Resolution Chain",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
console.print("[green]✓ OK[/green] Config read")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
|
||||
Reference in New Issue
Block a user