Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/command_system/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions src/command_system/doctor_command.py
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 8 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/entrypoints/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions src/permissions/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions src/services/config_health.py
Original file line number Diff line number Diff line change
@@ -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"]
40 changes: 40 additions & 0 deletions src/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand Down
5 changes: 5 additions & 0 deletions src/tui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
# components C5:
"/search",
"/open",
# components C6:
"/doctor",
)


Expand Down Expand Up @@ -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",
}


Expand Down Expand Up @@ -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
Expand Down
Loading