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

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:
2026-04-05 17:51:31 +00:00
parent 1783f0a211
commit 8be1359ddd
9 changed files with 682 additions and 51 deletions
+2 -2
View File
@@ -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"
+120
View File
@@ -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())}"
)
+5 -3
View File
@@ -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())}"
+229 -30
View File
@@ -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")