diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index 14f40c28d..eefaba3ec 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -41,6 +41,7 @@ from .memory_command import MEMORY_COMMAND from .stickers_command import STICKERS_COMMAND from .rename_command import RENAME_COMMAND +from .doctor_command import DOCTOR_COMMAND from .resume_command import RESUME_COMMAND from .types import Command, CommandType, CompactionResult, LocalCommand, PromptCommand @@ -1262,6 +1263,7 @@ def get_builtin_commands() -> list[Command]: STICKERS_COMMAND, RENAME_COMMAND, RESUME_COMMAND, + DOCTOR_COMMAND, ] if is_buddy_command_enabled(): cmds.append(BUDDY_COMMAND) diff --git a/src/command_system/doctor_command.py b/src/command_system/doctor_command.py new file mode 100644 index 000000000..a67155a32 --- /dev/null +++ b/src/command_system/doctor_command.py @@ -0,0 +1,88 @@ +"""doctor — ``/doctor`` diagnostics (port of TS local-jsx, components C6). + +TS ``/doctor`` (``commands/doctor``) renders ``DiagnosticsDisplay``. +Python's rich surface is the (previously dormant, now wired) TUI +``DoctorScreen``; this registry command serves the NON-TUI surfaces in +the output-style precedent — ``run()`` returns a text report without +touching ``ctx.ui`` (headless/REPL/SDK safe). + +Coexistence: **inversion** — the TUI intercepts ``/doctor`` and pushes +the screen; this command serves everything else. +""" + +from __future__ import annotations + +import platform +import sys +from dataclasses import dataclass + +from .types import ( + CommandContext, + InteractiveCommand, + InteractiveOutcome, +) + + +def build_doctor_report(cwd: str | None = None) -> str: + from src.services.config_health import collect_config_warnings + + lines: list[str] = ["Diagnostics:"] + lines.append( + f"• python {platform.python_version()} on " + f"{platform.system()} {platform.machine()} ({sys.executable})" + ) + try: + from src.tool_system.utils.ripgrep import find_ripgrep + + rg = find_ripgrep() + lines.append(f"• ripgrep: {rg or 'NOT FOUND (file search degraded)'}") + except Exception: + lines.append("• ripgrep: check failed") + try: + from src.services.session_storage import SESSIONS_DIR + + lines.append(f"• sessions dir: {SESSIONS_DIR}") + except Exception: + pass + try: + warnings = collect_config_warnings(cwd) + except Exception: + warnings = [] + lines.append("• config health: check failed") + if warnings: + lines.append("Config problems:") + lines.extend(f" ⚠ {w.message()}" for w in warnings) + else: + lines.append("• config files: OK") + try: + from src.services.config_health import collect_rule_warnings + + rule_warnings = collect_rule_warnings(cwd) + except Exception: + rule_warnings = [] + if rule_warnings: + lines.append("Permission-rule warnings:") + lines.extend(f" ⚠ {w}" for w in rule_warnings) + return "\n".join(lines) + + +@dataclass(frozen=True) +class DoctorCommand(InteractiveCommand): + """Environment + config health report (text on every surface).""" + + async def run(self, args: str, context: CommandContext) -> InteractiveOutcome: + cwd = str(getattr(context, "cwd", "") or "") or None + return InteractiveOutcome( + message=build_doctor_report(cwd), display="system" + ) + + +DOCTOR_COMMAND = DoctorCommand( + name="doctor", + # TS doctor/index.ts:6 with the product name made neutral. + description="Diagnose and verify your installation and settings", + argument_hint="", +) + + +__all__ = ["DOCTOR_COMMAND", "DoctorCommand", "build_doctor_report"] diff --git a/src/config.py b/src/config.py index a460e09e0..8b44ea39a 100644 --- a/src/config.py +++ b/src/config.py @@ -93,10 +93,17 @@ def _read_json(path: Path) -> dict[str, Any]: return {} try: with open(path, "r", encoding="utf-8") as f: - return json.load(f) + data = json.load(f) except Exception as exc: logger.debug("Failed to read config %s: %s", path, exc) return {} + if not isinstance(data, dict): + # A non-object top level previously flowed into _deep_merge and + # raised AttributeError at the call site (C6 review M2) — treat + # it like any other unreadable config: ignored. + logger.debug("Ignoring non-object config %s", path) + return {} + return data def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: diff --git a/src/entrypoints/tui.py b/src/entrypoints/tui.py index 4dfcf0343..afdc1f12c 100644 --- a/src/entrypoints/tui.py +++ b/src/entrypoints/tui.py @@ -111,7 +111,8 @@ def run_tui(options: TUIOptions) -> int: # C1: load persisted permission rules (settings files) at startup so # "always allow" rules from prior sessions are live — the rule engine # ran against empty rule sets before this. Setup warnings (dangerous / - # shadowed rules) intentionally unsurfaced until phase C6. + # shadowed rules) surface as startup transcript rows via + # services/config_health.collect_rule_warnings (C6). from src.permissions.settings_paths import default_setup_paths from src.permissions.setup import setup_permissions diff --git a/src/permissions/setup.py b/src/permissions/setup.py index 28fc720fb..8441ec0cc 100644 --- a/src/permissions/setup.py +++ b/src/permissions/setup.py @@ -53,10 +53,18 @@ def _load_settings_file(path: str) -> dict[str, Any] | None: if not os.path.isfile(path): return None try: - with open(path, "r") as f: - return json.load(f) - except (json.JSONDecodeError, OSError): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (ValueError, OSError): + # ValueError covers JSONDecodeError AND UnicodeDecodeError — a + # mis-encoded settings file must degrade to "ignored", not crash + # setup_permissions before the TUI mounts (C6 review M2). return None + if not isinstance(data, dict): + # A top-level array previously escaped here and blew up in + # _extract_permissions (AttributeError) — same crash class. + return None + return data def _extract_permissions(settings: dict[str, Any] | None) -> dict[str, Any] | None: diff --git a/src/services/config_health.py b/src/services/config_health.py new file mode 100644 index 000000000..a63999967 --- /dev/null +++ b/src/services/config_health.py @@ -0,0 +1,136 @@ +"""Config/settings file health checks (components C6). + +Degraded port of the TS validation surfaces (``InvalidConfigDialog`` / +``InvalidSettingsDialog`` / ``ValidationErrorsList`` / ``StatusNotices`` +startup warnings): Python's config loader (`config.py _read_json`) +silently falls back to ``{}`` on a malformed file — the session works, +but the user's settings are IGNORED with no signal. This module turns +that into honest startup warnings; the TUI shows them as transcript +rows and ``/doctor`` lists them. + +Deliberate divergences: NO reset-vs-exit gate (TS InvalidConfigDialog +offers one) — both loaders now degrade to "file ignored" on every +problem class this module detects (C6 hardened ``config._read_json`` +and ``permissions.setup._load_settings_file`` to guarantee it), so a +blocking dialog would gate on a condition that cannot occur. +``KeybindingWarnings.tsx`` is NOT ported here — it rides on the parked +user-keybindings-file subsystem (the `/keybindings` deferral). +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ConfigWarning: + path: str + problem: str + + def message(self) -> str: + return f"{self.path}: {self.problem} — file ignored" + + +def _check_json_file(path: str) -> ConfigWarning | None: + if not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as exc: + return ConfigWarning(path=path, problem=f"invalid JSON ({exc.msg} at line {exc.lineno})") + except UnicodeDecodeError: + return ConfigWarning(path=path, problem="invalid encoding (not UTF-8)") + except OSError as exc: + return ConfigWarning(path=path, problem=f"unreadable ({exc.strerror or exc})") + if not isinstance(data, dict): + return ConfigWarning( + path=path, problem="top level must be a JSON object" + ) + return None + + +def collect_config_warnings(cwd: str | None = None) -> list[ConfigWarning]: + """Health-check every config/settings file the session reads. + + Project config paths resolve exactly like the loader does + (git-root-anchored via ``get_project_config_path`` — review M1); the + permission-settings trio + managed file use ``settings_paths``. + """ + + from src.config import ( + GLOBAL_CONFIG_DIR, + get_local_config_path, + get_project_config_path, + ) + from src.permissions.settings_paths import ( + local_settings_path, + project_settings_path, + user_settings_path, + ) + from src.settings.managed_path import resolve_managed_settings_path + + base = cwd or os.getcwd() + candidates: list[str] = [str(GLOBAL_CONFIG_DIR / "config.json")] + project_cfg = get_project_config_path(base) + if project_cfg is not None: + candidates.append(str(project_cfg)) + local_cfg = get_local_config_path(base) + if local_cfg is not None: + candidates.append(str(local_cfg)) + candidates.extend( + [ + user_settings_path(), + project_settings_path(base), + local_settings_path(base), + ] + ) + try: + managed = resolve_managed_settings_path() + if managed is not None: + candidates.append(str(managed)) + except Exception: + pass + warnings: list[ConfigWarning] = [] + seen: set[str] = set() + for path in candidates: + if path in seen: + continue + seen.add(path) + warning = _check_json_file(path) + if warning is not None: + warnings.append(warning) + return warnings + + +def collect_rule_warnings(cwd: str | None = None) -> list[str]: + """Dangerous + shadowed permission-rule warnings (C6 review M3 — + delivers the surfacing the C1 wiring deferred to this phase; TS + ValidationErrorsList family).""" + + from src.permissions.settings_paths import default_setup_paths + from src.permissions.setup import setup_permissions + + base = cwd or os.getcwd() + try: + setup = setup_permissions(cwd=base, **default_setup_paths(base)) + except Exception: + return [] + out: list[str] = [] + for warning in setup.warnings: + content = f"({warning.rule_content})" if warning.rule_content else "" + out.append( + f"dangerous permission rule {warning.tool_name}{content} " + f"in {warning.source}" + ) + for allow_rule, deny_rule in setup.shadowed_rules: + out.append( + f"allow rule {allow_rule.rule_value.tool_name} is shadowed by " + f"deny rule {deny_rule.rule_value.tool_name}" + ) + return out + + +__all__ = ["ConfigWarning", "collect_config_warnings", "collect_rule_warnings"] diff --git a/src/tui/app.py b/src/tui/app.py index 28b9c603b..f6a684e2d 100644 --- a/src/tui/app.py +++ b/src/tui/app.py @@ -222,6 +222,37 @@ def on_mount(self) -> None: except Exception: pass self._state_unsub = self.app_state.subscribe(self._on_state_change) + # C6: surface ignored/malformed config files as startup rows + # (TS StatusNotices / InvalidSettingsDialog family — Python's + # loader silently falls back to {}, so warn honestly instead). + self.call_after_refresh(self._show_config_warnings) + + def _show_config_warnings(self) -> None: + if self._repl_screen is None: + return + try: + from src.services.config_health import ( + collect_config_warnings, + collect_rule_warnings, + ) + + warnings = collect_config_warnings(str(self.workspace_root)) + rule_warnings = collect_rule_warnings(str(self.workspace_root)) + transcript = self._repl_screen.transcript + for warning in warnings: + transcript.append_system( + f"⚠ {warning.message()}", style="warning" + ) + for text in rule_warnings: + transcript.append_system(f"⚠ {text}", style="warning") + if warnings or rule_warnings: + transcript.append_system( + "Run /doctor for details.", style="muted" + ) + except Exception: + # A health-check failure must never crash app mount (the + # call_after_refresh callback would take the app down). + return def on_unmount(self) -> None: # Best-effort cleanup so we don't leave stale chrome on the host. @@ -561,6 +592,15 @@ def _open_phase2_dialog(self, name: str, transcript: Transcript) -> None: ) elif name == "quickopen": self._open_quick_open(transcript) + elif name == "doctor": + from src.tui.screens.doctor import DoctorScreen + + self.push_screen( + DoctorScreen( + app_state=self.app_state, + workspace_root=self.workspace_root, + ) + ) else: transcript.append_system(f"Dialog '{name}' not available.", style="muted") diff --git a/src/tui/commands.py b/src/tui/commands.py index be714da54..a35129de5 100644 --- a/src/tui/commands.py +++ b/src/tui/commands.py @@ -60,6 +60,8 @@ # components C5: "/search", "/open", + # components C6: + "/doctor", ) @@ -135,6 +137,7 @@ def slash(self) -> str: "/thinking": "Toggle extended thinking for this session", "/search": "Search the workspace (insert @file#Lline)", "/open": "Quick-open a file (insert @path)", + "/doctor": "Diagnose and verify your installation and settings", } @@ -321,6 +324,8 @@ def dispatch_local_command( ) if name == "/open": return CommandDispatchResult(handled=True, open_dialog="quickopen") + if name == "/doctor": + return CommandDispatchResult(handled=True, open_dialog="doctor") if name in ("/resume", "/continue"): # /continue = TS alias (commands/resume/index.ts:7). Intercepted # here too so the TUI opens the picker instead of falling through diff --git a/src/tui/screens/doctor.py b/src/tui/screens/doctor.py index 6156b67be..f2a5208d1 100644 --- a/src/tui/screens/doctor.py +++ b/src/tui/screens/doctor.py @@ -50,9 +50,14 @@ class DoctorScreen(ModalScreen[None]): } """ - def __init__(self, app_state: Any | None = None) -> None: + def __init__( + self, + app_state: Any | None = None, + workspace_root: Any | None = None, + ) -> None: super().__init__() self._app_state = app_state + self._workspace_root = workspace_root def compose(self) -> ComposeResult: with Middle(): @@ -90,11 +95,57 @@ def action_dismiss_modal(self) -> None: def _collect_sections(self) -> list[tuple[str, Any]]: sections: list[tuple[str, Any]] = [] sections.append(("environment", self._render_environment())) + sections.append(("config health", self._render_config_health())) + sections.append(("permission rules", self._render_rule_health())) sections.append(("hyperlinks", self._render_hyperlinks())) sections.append(("frame metrics", self._render_frame_metrics())) sections.append(("storage", self._render_storage())) return sections + def _render_config_health(self) -> Any: + """C6: malformed/ignored config files (services/config_health).""" + + try: + from src.services.config_health import collect_config_warnings + + # Same root the startup rows use (m5): the app passes its + # workspace_root at push time. + warnings = collect_config_warnings( + str(self._workspace_root) if self._workspace_root else None + ) + except Exception: + return Text("check failed", style="dim") + if not warnings: + return Text("all config files OK", style="green") + body = Text() + for i, warning in enumerate(warnings): + if i: + body.append("\n") + body.append(f"⚠ {warning.message()}", style="yellow") + return body + + def _render_rule_health(self) -> Any: + """C6: dangerous/shadowed permission rules — same data the + startup rows and the headless report show (review follow-up: + the "Run /doctor" hint must lead somewhere that has it).""" + + try: + from src.services.config_health import collect_rule_warnings + + warnings = collect_rule_warnings( + str(self._workspace_root) if self._workspace_root else None + ) + except Exception: + return Text("check failed", style="dim") + if not warnings: + return Text("no rule warnings", style="green") + body = Text() + for i, warning in enumerate(warnings): + if i: + body.append("\n") + body.append(f"⚠ {warning}", style="yellow") + return body + def _render_environment(self) -> Any: """Provider, model, workspace, theme — read from app state when present.""" diff --git a/src/tui/widgets/messages/base.py b/src/tui/widgets/messages/base.py index 3ac0eb4e8..1271eb4e8 100644 --- a/src/tui/widgets/messages/base.py +++ b/src/tui/widgets/messages/base.py @@ -48,6 +48,9 @@ class SystemMessage(BaseRow): SystemMessage.-error > Static { color: $error; } + SystemMessage.-warning > Static { + color: $warning; + } SystemMessage.-muted > Static { color: $text-muted; } @@ -59,6 +62,8 @@ def __init__(self, text: str, *, style: str = "muted") -> None: self._style = style if style == "error": self.add_class("-error") + elif style == "warning": + self.add_class("-warning") else: self.add_class("-muted") diff --git a/src/tui/widgets/transcript_view.py b/src/tui/widgets/transcript_view.py index a24c050c1..5c95f3be0 100644 --- a/src/tui/widgets/transcript_view.py +++ b/src/tui/widgets/transcript_view.py @@ -700,6 +700,8 @@ def _canonical_system_style(style: str) -> str: key = style.strip().lower() if key in ("red", "error", "danger"): return "error" + if key in ("warning", "yellow", "warn"): + return "warning" return "muted" diff --git a/tests/conftest.py b/tests/conftest.py index 960a57e78..6ada57206 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,15 @@ def _isolate_user_permission_settings(tmp_path, monkeypatch): isolated = str(tmp_path / "isolated-user-settings.json") monkeypatch.setattr(settings_paths, "user_settings_path", lambda: isolated) + # C6: the startup health check would otherwise read the developer's + # real ~/.clawcodex/config.json in every full-app test — a malformed + # file on a dev machine would inject warning rows into unrelated + # assertions. + import src.config as config_mod + + monkeypatch.setattr( + config_mod, "GLOBAL_CONFIG_DIR", tmp_path / "isolated-global" + ) yield diff --git a/tests/tui/test_doctor_validation_c6.py b/tests/tui/test_doctor_validation_c6.py new file mode 100644 index 000000000..bffc3ad9e --- /dev/null +++ b/tests/tui/test_doctor_validation_c6.py @@ -0,0 +1,244 @@ +"""C6 tests: config health checks, /doctor wiring, startup warnings.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from src.services.config_health import collect_config_warnings + + +@pytest.fixture +def isolated_paths(tmp_path, monkeypatch): + """Point every checked config path into tmp. + + ``_find_git_root`` is pinned to tmp so the loader-faithful + git-root-anchored project paths (review M1) resolve inside the + fixture rather than walking up into the real repo. + """ + + import src.config as config_mod + from src.permissions import settings_paths + + monkeypatch.setattr(config_mod, "GLOBAL_CONFIG_DIR", tmp_path / "global") + monkeypatch.setattr( + config_mod, "_find_git_root", lambda cwd=None: tmp_path + ) + monkeypatch.setattr( + settings_paths, + "user_settings_path", + lambda: str(tmp_path / "user-settings.json"), + ) + return tmp_path + + +class TestConfigHealth: + def test_clean_tree_no_warnings(self, isolated_paths) -> None: + assert collect_config_warnings(str(isolated_paths)) == [] + + def test_malformed_json_warns(self, isolated_paths) -> None: + cfg_dir = isolated_paths / ".claude" + cfg_dir.mkdir() + (cfg_dir / "config.json").write_text("{not json") + warnings = collect_config_warnings(str(isolated_paths)) + assert len(warnings) == 1 + assert "invalid JSON" in warnings[0].problem + assert "config.json" in warnings[0].path + assert "file ignored" in warnings[0].message() + + def test_non_object_top_level_warns(self, isolated_paths) -> None: + clawdir = isolated_paths / ".clawcodex" + clawdir.mkdir() + (clawdir / "settings.json").write_text("[1, 2, 3]") + warnings = collect_config_warnings(str(isolated_paths)) + assert len(warnings) == 1 + assert "JSON object" in warnings[0].problem + + def test_valid_files_pass(self, isolated_paths) -> None: + clawdir = isolated_paths / ".clawcodex" + clawdir.mkdir() + (clawdir / "settings.local.json").write_text( + '{"permissions": {"allow": []}}' + ) + assert collect_config_warnings(str(isolated_paths)) == [] + + def test_bad_encoding_warns(self, isolated_paths) -> None: + cfg_dir = isolated_paths / ".claude" + cfg_dir.mkdir() + (cfg_dir / "config.local.json").write_bytes(b'{"a": "\xff\xfe"}') + warnings = collect_config_warnings(str(isolated_paths)) + assert len(warnings) == 1 + assert "encoding" in warnings[0].problem + + def test_unreadable_file_warns(self, isolated_paths) -> None: + import os + import stat + + if os.geteuid() == 0: # pragma: no cover — root ignores modes + pytest.skip("permission bits ignored as root") + clawdir = isolated_paths / ".clawcodex" + clawdir.mkdir() + target = clawdir / "settings.json" + target.write_text("{}") + target.chmod(0) + try: + warnings = collect_config_warnings(str(isolated_paths)) + assert len(warnings) == 1 + assert "unreadable" in warnings[0].problem + finally: + target.chmod(stat.S_IRUSR | stat.S_IWUSR) + + def test_directory_named_config_is_skipped(self, isolated_paths) -> None: + cfg_dir = isolated_paths / ".claude" + cfg_dir.mkdir() + (cfg_dir / "config.json").mkdir() # directory, not a file + assert collect_config_warnings(str(isolated_paths)) == [] + + +class TestLoaderHardening: + """Review M2: every detected problem class must actually be IGNORED + by the loaders (not crash startup) so 'file ignored' is true.""" + + def test_settings_loader_survives_array_and_encoding(self, tmp_path) -> None: + from src.permissions.setup import _load_settings_file + + arr = tmp_path / "arr.json" + arr.write_text("[1, 2]") + assert _load_settings_file(str(arr)) is None + bad = tmp_path / "bad.json" + bad.write_bytes(b'{"a": "\xff\xfe"}') + assert _load_settings_file(str(bad)) is None + + def test_setup_permissions_survives_malformed_settings(self, tmp_path) -> None: + from src.permissions.setup import setup_permissions + + local = tmp_path / "settings.local.json" + local.write_text("[not an object]") + result = setup_permissions( + cwd=str(tmp_path), local_settings_path=str(local) + ) + assert result.context is not None + + def test_config_reader_ignores_non_object(self, tmp_path) -> None: + from src.config import _read_json + + path = tmp_path / "cfg.json" + path.write_text("[1, 2, 3]") + assert _read_json(path) == {} + + +class TestRuleWarnings: + def test_dangerous_rule_surfaces(self, isolated_paths) -> None: + import json + + from src.services.config_health import collect_rule_warnings + + clawdir = isolated_paths / ".clawcodex" + clawdir.mkdir() + (clawdir / "settings.local.json").write_text( + json.dumps({"permissions": {"allow": ["Bash"]}}) + ) + warnings = collect_rule_warnings(str(isolated_paths)) + assert any("dangerous permission rule Bash" in w for w in warnings) + + def test_clean_rules_no_warnings(self, isolated_paths) -> None: + from src.services.config_health import collect_rule_warnings + + assert collect_rule_warnings(str(isolated_paths)) == [] + + +class TestDoctorCommand: + @pytest.mark.asyncio + async def test_headless_report_with_real_context(self, isolated_paths) -> None: + # REAL CommandContext (review m9): pins the `cwd` field name a + # MagicMock would silently mask. + from src.command_system.doctor_command import DOCTOR_COMMAND + from src.command_system.types import CommandContext + + ctx = CommandContext( + workspace_root=isolated_paths, + cwd=isolated_paths, + conversation=MagicMock(), + cost_tracker=MagicMock(), + history=MagicMock(), + ) + outcome = await DOCTOR_COMMAND.run("", ctx) + assert "Diagnostics:" in outcome.message + assert "python" in outcome.message + assert "config files: OK" in outcome.message + + @pytest.mark.asyncio + async def test_report_lists_problems(self, isolated_paths) -> None: + from src.command_system.doctor_command import DOCTOR_COMMAND + + (isolated_paths / ".claude").mkdir() + (isolated_paths / ".claude" / "config.json").write_text("{oops") + ctx = MagicMock() + ctx.cwd = str(isolated_paths) + outcome = await DOCTOR_COMMAND.run("", ctx) + assert "Config problems:" in outcome.message + assert "invalid JSON" in outcome.message + + def test_headless_import_is_textual_free(self) -> None: + import subprocess + import sys + + code = ( + "import sys; import asyncio; " + "from unittest.mock import MagicMock; " + "from src.command_system.doctor_command import DOCTOR_COMMAND; " + "asyncio.run(DOCTOR_COMMAND.run('', MagicMock())); " + "sys.exit(1 if any(m.startswith('textual') for m in sys.modules) else 0)" + ) + proc = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + assert proc.returncode == 0, proc.stderr + + +class TestDispatchAndStartupRows: + def test_doctor_dispatch(self) -> None: + from src.tui.commands import dispatch_local_command + + result = dispatch_local_command( + "/doctor", session=None, workspace_root=Path("."), tool_registry=None + ) + assert result.handled and result.open_dialog == "doctor" + + def test_startup_rows_emitted(self, isolated_paths) -> None: + from src.tui.app import ClawCodexTUI + + (isolated_paths / ".claude").mkdir() + (isolated_paths / ".claude" / "config.json").write_text("{oops") + rows: list[tuple[str, str]] = [] + fake = SimpleNamespace( + workspace_root=isolated_paths, + _repl_screen=SimpleNamespace( + transcript=SimpleNamespace( + append_system=lambda text, style="muted": rows.append( + (style, text) + ) + ) + ), + ) + ClawCodexTUI._show_config_warnings(fake) + assert any("invalid JSON" in text for _s, text in rows) + assert any("/doctor" in text for _s, text in rows) + + def test_startup_rows_silent_when_clean(self, isolated_paths) -> None: + from src.tui.app import ClawCodexTUI + + rows: list[str] = [] + fake = SimpleNamespace( + workspace_root=isolated_paths, + _repl_screen=SimpleNamespace( + transcript=SimpleNamespace( + append_system=lambda text, style="muted": rows.append(text) + ) + ), + ) + ClawCodexTUI._show_config_warnings(fake) + assert rows == []