diff --git a/CHANGELOG.md b/CHANGELOG.md index 0391521..95a805f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 207f669..ae04105 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/context_analyzer_tool/cli.py b/src/context_analyzer_tool/cli.py index 2aa6d7b..088b3e8 100644 --- a/src/context_analyzer_tool/cli.py +++ b/src/context_analyzer_tool/cli.py @@ -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, ) @@ -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"), diff --git a/src/context_analyzer_tool/config.py b/src/context_analyzer_tool/config.py index 868538e..c76921b 100644 --- a/src/context_analyzer_tool/config.py +++ b/src/context_analyzer_tool/config.py @@ -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. diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..59f69fe --- /dev/null +++ b/tests/test_cli_config.py @@ -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