Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Added

- **`context-analyzer-tool config show`** (#4) — display effective configuration (TOML file path, merged values, optional highlight for non-default overrides). Supports `--format table|toml` and `--config`.

## 0.3.1 (2026-04-08)

### Bug Fixes
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ context-analyzer-tool status View active sessions and recent tasks
context-analyzer-tool anomalies List recent anomalies with root causes
context-analyzer-tool context-cost Show context cost breakdown
context-analyzer-tool health Collector health check
context-analyzer-tool config show Display effective configuration (TOML + env)
context-analyzer-tool rtk-status Show RTK integration status and savings
context-analyzer-tool prune Clean up old data
context-analyzer-tool clear Clear all stored data and start fresh
Expand Down
113 changes: 113 additions & 0 deletions src/context_analyzer_tool/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
from rich.table import Table

from context_analyzer_tool.config import (
CATConfig,
config_to_toml,
get_config_dir,
get_config_path,
load_config,
load_default_config,
write_default_config,
)

Expand Down Expand Up @@ -259,6 +262,116 @@ def _truncate_session_id(session_id: str, length: int = 8) -> str:
# ---------------------------------------------------------------------------


def _format_config_value(value: object) -> str:
"""Format a config field value for CLI display."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
if not value:
return "[]"
return ", ".join(str(item) for item in value)
return str(value)


def _iter_config_fields(cfg: CATConfig) -> list[tuple[str, str, object]]:
"""Yield (section, field, value) tuples for every config field."""
rows: list[tuple[str, str, object]] = []
for section_name in (
"collector",
"anomaly",
"classifier",
"notifications",
"hooks",
"server",
"retention",
"dashboard",
):
section = getattr(cfg, section_name)
for field_name in type(section).model_fields:
rows.append((section_name, field_name, getattr(section, field_name)))
return rows


config_app = typer.Typer(
help="Inspect effective configuration (TOML file + CAT_* env overrides)",
)


@config_app.command("show")
def config_show(
config_path: Path | None = typer.Option(
None, "--config", help="Config file path (default: ~/.context-analyzer-tool/config.toml)"
),
output_format: str = typer.Option(
"table",
"--format",
"-f",
help="Output format: table or toml",
),
highlight: bool = typer.Option(
True,
"--highlight/--no-highlight",
help="Highlight values that differ from built-in defaults",
),
) -> None:
"""Show the effective configuration after merging TOML and env overrides."""
path = (config_path if config_path is not None else get_config_path()).expanduser()
try:
cfg = load_config(config_path)
except ValueError as exc:
console.print(f"[red]Configuration error:[/red] {exc}")
raise typer.Exit(1) from None

defaults = load_default_config()
default_lookup = {
(section, field): value
for section, field, value in _iter_config_fields(defaults)
}

exists = path.exists()
source_note = (
"[green]found[/green]" if exists else "[yellow]missing (using defaults)[/yellow]"
)
console.print(f"Config file: [bold]{path}[/bold] ({source_note})")
console.print(f"Config dir: [bold]{get_config_dir()}[/bold]")
console.print()

if output_format == "toml":
console.print(config_to_toml(cfg), markup=False, end="")
return
if output_format != "table":
console.print(f"[red]Unknown format:[/red] {output_format!r} (use table or toml)")
raise typer.Exit(2)

table = Table(show_header=True, header_style="bold cyan", expand=True)
table.add_column("Section")
table.add_column("Key")
table.add_column("Value")
if highlight:
table.add_column("Note")

for section, field, value in _iter_config_fields(cfg):
default_value = default_lookup[(section, field)]
value_str = _format_config_value(value)
if highlight and value != default_value:
value_str = f"[yellow]{value_str}[/yellow]"
note = "[yellow]override[/yellow]"
elif highlight:
note = "[dim]default[/dim]"
else:
note = ""

if highlight:
table.add_row(section, field, value_str, note)
else:
table.add_row(section, field, value_str)

console.print(Panel(table, title="Effective Configuration", border_style="blue"))


app.add_typer(config_app, name="config")


@app.command()
def serve(
host: str | None = typer.Option(None, help="Bind host"),
Expand Down
40 changes: 40 additions & 0 deletions src/context_analyzer_tool/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,46 @@ def _apply_env_overrides(self) -> CATConfig:
# ---------------------------------------------------------------------------


def load_default_config() -> CATConfig:
"""Return built-in defaults without ``CAT_*`` environment overrides."""
saved: dict[str, str | None] = {}
for key in list(os.environ):
if key.startswith(_ENV_PREFIX):
saved[key] = os.environ.pop(key)
try:
return CATConfig()
finally:
for key, value in saved.items():
if value is not None:
os.environ[key] = value


def config_to_toml(cfg: CATConfig) -> str:
"""Serialize *cfg* to a TOML-like string for display."""
lines: list[str] = []
for section_name, section in cfg.model_dump().items():
lines.append(f"[{section_name}]")
for key, value in section.items():
if isinstance(value, bool):
rendered = "true" if value else "false"
elif isinstance(value, str):
rendered = f'"{value}"'
elif isinstance(value, list):
if not value:
rendered = "[]"
else:
parts = [
f'"{item}"' if isinstance(item, str) else str(item)
for item in value
]
rendered = f"[{', '.join(parts)}]"
else:
rendered = str(value)
lines.append(f"{key} = {rendered}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"


def load_config(config_path: Path | None = None) -> CATConfig:
"""Load config from a TOML file.

Expand Down
81 changes: 81 additions & 0 deletions tests/test_cli_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Tests for the `config show` CLI command (#4)."""

from __future__ import annotations

from pathlib import Path

from typer.testing import CliRunner

from context_analyzer_tool.cli import app
from context_analyzer_tool.config import config_to_toml, load_config, load_default_config

runner = CliRunner()


def test_config_show_table_default(monkeypatch) -> None:
"""`config show` prints the config path and effective values."""
with runner.isolated_filesystem():
cfg_dir = Path("cfg")
cfg_dir.mkdir()
monkeypatch.setenv("CAT_CONFIG_DIR", str(cfg_dir))

result = runner.invoke(app, ["config", "show"])

assert result.exit_code == 0, result.output
assert "Config file:" in result.output
assert "config.toml" in result.output
assert "Effective Configuration" in result.output
assert "7821" in result.output


def test_config_show_toml_format(monkeypatch) -> None:
with runner.isolated_filesystem():
cfg_dir = Path("cfg")
cfg_dir.mkdir()
monkeypatch.setenv("CAT_CONFIG_DIR", str(cfg_dir))

result = runner.invoke(app, ["config", "show", "--format", "toml"])

assert result.exit_code == 0, result.output
assert "[collector]" in result.output
assert "port = 7821" in result.output


def test_config_show_highlights_env_override(monkeypatch) -> None:
with runner.isolated_filesystem():
cfg_dir = Path("cfg")
cfg_dir.mkdir()
monkeypatch.setenv("CAT_CONFIG_DIR", str(cfg_dir))
monkeypatch.setenv("CAT_COLLECTOR_PORT", "9001")

result = runner.invoke(app, ["config", "show"])

assert result.exit_code == 0, result.output
assert "9001" in result.output
assert "override" in result.output


def test_config_show_reads_custom_file(tmp_path: Path) -> None:
toml_file = tmp_path / "custom.toml"
toml_file.write_text(
"[collector]\nhost = \"10.0.0.1\"\nport = 8080\n",
encoding="utf-8",
)

result = runner.invoke(app, ["config", "show", "--config", str(toml_file)])

assert result.exit_code == 0, result.output
assert "custom.toml" in result.output
assert "10.0.0.1" in result.output
assert "8080" in result.output


def test_config_to_toml_round_trip_defaults() -> None:
cfg = load_default_config()
rendered = config_to_toml(cfg)
assert "[collector]" in rendered
assert 'host = "127.0.0.1"' in rendered

reloaded = load_config(Path("/nonexistent/config.toml"))
# load_config on missing file returns defaults; compare key fields
assert reloaded.collector.host == cfg.collector.host