diff --git a/src/ucode/cli.py b/src/ucode/cli.py index a7684e5f..badc01c8 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -13,6 +13,8 @@ import typer from rich.panel import Panel +from rich.table import Table +from rich.text import Text from typer import _click from typer.core import HAS_RICH, TyperCommand, TyperGroup, TyperOption @@ -79,8 +81,10 @@ ) from ucode.managed_config import ( ManagedConfigResult, + get_managed_config, get_model_recommendation, load_managed_state, + normalize_managed_config, refresh_managed_config, ) from ucode.managed_resolve import ( @@ -101,7 +105,6 @@ SKILLS_MCP_KIND, add_mcp_command, add_skills_command, - agents_share_one_scope, available_mcp_clients, configure_mcp_command, configure_skills_mcp_command, @@ -127,7 +130,6 @@ from ucode.smart_routing import v2 as smart_routing_v2 from ucode.smart_routing.claude_hooks import FIRST_PROMPT_SOCKET_ENV, ROUTE_FIRST_PROMPT_EVENT from ucode.state import ( - STATE_PATH, clear_state, get_provider_service, load_state, @@ -932,46 +934,233 @@ def configure_workspace_command( return 0 +def _print_status_panel(title: str, rows: list[tuple[str, str]]) -> None: + """Render a compact two-column status card with values that wrap safely.""" + table = Table.grid(padding=(0, 2)) + table.add_column(no_wrap=True) + table.add_column() + for key, value in rows: + table.add_row(Text(f"{key}:", style="bold"), Text(value, style="cyan")) + console.print( + Panel( + table, + title=title, + border_style="blue", + width=min(console.width, 120), + ) + ) + + +def _model_values(value: object) -> list[str]: + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [item for item in value if isinstance(item, str) and item] + if isinstance(value, dict): + return [model for models in value.values() for model in _model_values(models)] + return [] + + +def _status_models(tool: str, state: dict) -> list[str]: + """Return the effective model allow-list for one configured agent.""" + static_models = _model_values(state.get(f"{tool}_static_models")) + if static_models: + models = static_models + elif tool in ("claude", "codex", "gemini", "opencode"): + models = _model_values(state.get(f"{tool}_models")) + elif tool == "copilot": + models = _model_values(state.get("copilot_models")) or ( + _model_values(state.get("claude_models")) + _model_values(state.get("codex_models")) + ) + elif tool == "pi": + models = _model_values(state.get("pi_models")) or ( + _model_values(state.get("claude_models")) + + _model_values(state.get("codex_models")) + + _model_values(state.get("gemini_models")) + ) + else: + models = [] + return list(dict.fromkeys(models)) + + +def _status_default_model(tool: str, state: dict, models: list[str]) -> str | None: + explicit = state.get(f"{tool}_default_model") + if isinstance(explicit, str) and explicit: + return explicit + # Claude and Codex deliberately leave the starting model to the agent unless a managed + # config pins one. The other clients write the first resolved model into their ug config. + return models[0] if models and tool in ("gemini", "opencode", "copilot", "pi") else None + + +def _status_skill_scope(state: dict, tool: str) -> str: + entries = (state.get("mcp_servers") or []) + (state.get("managed_mcp_servers") or []) + entry = next( + ( + server + for server in entries + if server.get("kind") == SKILLS_MCP_KIND and tool in (server.get("clients") or []) + ), + None, + ) + if entry is None: + return "not configured" + locations = skill_locations_for_client(entry, tool) + return ", ".join(locations) if locations else "utility tools only" + + +def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: + """Return a fresh, read-only model inventory and its freshness label.""" + workspace = state.get("workspace") + if not workspace or not tools: + return state, "cached" + profile = state.get("profile") + if not profile and not external_bearer_configured(): + print_warning("Live model discovery needs the CLI profile saved by ug configure.") + return state, "cached" + + try: + if state.get("use_pat"): + apply_pat_environment(state) + with spinner("Refreshing live workspace models..."): + token = get_databricks_token(workspace, profile) + claude_models, codex_models, gemini_models, oss_models, shared_reason = ( + discover_model_services(workspace, token) + ) + reasons: dict[str, str | None] = {} + if not claude_models: + claude_models, reasons["claude"] = discover_claude_models(workspace, token) + if not codex_models: + codex_models, reasons["codex"] = discover_codex_models(workspace, token) + if not gemini_models: + gemini_models, reasons["gemini"] = discover_gemini_models(workspace, token) + except RuntimeError as exc: + print_warning(f"Live model discovery failed ({exc}); showing cached models.") + return state, "cached" + + live = dict(state) + live["claude_models"] = claude_models + live["codex_models"] = codex_models + live["gemini_models"] = gemini_models + live["oss_models"] = oss_models + opencode_models: dict[str, list[str]] = {} + if claude_models: + opencode_models["anthropic"] = list(claude_models.values()) + if gemini_models: + opencode_models["gemini"] = gemini_models + if oss_models: + opencode_models["oss"] = oss_models + live["opencode_models"] = opencode_models + live["_status_model_reasons"] = { + family: reason or shared_reason for family, reason in reasons.items() + } + return live, "live" + + +def _live_status_managed_state(state: dict, cached: dict | None) -> tuple[dict | None, str]: + """Return the current managed policy without updating the on-disk cache.""" + workspace = state.get("workspace") + if not workspace: + return cached, "cached" + profile = state.get("profile") + if not profile and not external_bearer_configured(): + print_warning("Live managed configuration needs the CLI profile saved by ug configure.") + return cached, "cached" + + try: + if state.get("use_pat"): + apply_pat_environment(state) + with spinner("Refreshing live managed configuration..."): + token = get_databricks_token(workspace, profile) + raw, reason = get_managed_config(workspace, token) + except RuntimeError as exc: + print_warning(f"Live managed configuration failed ({exc}); showing cached configuration.") + return cached, "cached" + + if reason is not None: + if "feature_disabled" in reason.lower(): + return None, "live" + print_warning( + f"Live managed configuration failed ({reason}); showing cached configuration." + ) + return cached, "cached" + return (normalize_managed_config(raw) if raw is not None else None), "live" + + def status() -> int: state = load_state() workspace = state.get("workspace") managed_configs = state.get("managed_configs") or {} # Both developer- and workspace-managed servers, so the count agrees with `ug mcp list`. mcp_servers = (state.get("mcp_servers") or []) + (state.get("managed_mcp_servers") or []) - configured_tools = set(state.get("available_tools") or managed_configs.keys()) + cached_managed = load_managed_state(workspace) if workspace else None + managed, managed_freshness = _live_status_managed_state(state, cached_managed) + configured_tools = ( + set(state.get("available_tools") or []) + | set(managed_configs) + | set((managed or {}).get("enabled_agents") or {}) + ) console.print(heading("ug status")) console.print( f" {status_badge('Configured', 'ok') if workspace else status_badge('Not Configured', 'warn')}" ) - print_heading("Provider") - print_kv("Workspace URL", workspace or "not configured") + provider_rows = [("Workspace URL", workspace or "not configured")] profile = state.get("profile") if profile: - print_kv("CLI profile", profile) - - if workspace: - managed = load_managed_state(workspace) - if managed: - _print_managed_summary(managed, state, None) + provider_rows.append(("CLI profile", profile)) + provider_rows.append( + ( + "Configuration", + f"Workspace-managed ({managed_freshness})" if managed else "Self-configured", + ) + ) + policy = (managed or {}).get("budget_policy") + if isinstance(policy, dict): + provider_rows.append(("Policy", str(policy.get("display_name") or "coding-agents-default"))) + _print_status_panel("Provider", provider_rows) + model_state, model_freshness = _live_status_model_state(state, configured_tools) print_heading("Coding Agents") for tool, spec in TOOL_SPECS.items(): - configured = tool in configured_tools - base_url = ( - state.get("base_urls", {}).get(tool, "not configured") - if configured - else "not configured" + if tool not in configured_tools: + continue + effective_state = resolve_state(managed, model_state, tool) if managed else model_state + agent_managed = tool in ((managed or {}).get("enabled_agents") or {}) + provider_service = get_provider_service(effective_state, tool) + rows = [ + ( + "Configuration", + f"Workspace-managed ({managed_freshness})" if agent_managed else "Self-configured", + ), + ( + "Model provider", + provider_service or "Databricks AI Gateway", + ), + ] + models = ( + [] + if provider_service and not effective_state.get(f"{tool}_static_models") + else _status_models(tool, effective_state) ) - config_path = spec["config_path"] - print_kv("Coding Agent", spec["display"]) - print_kv("Configured", "yes" if configured else "no") - provider_service = get_provider_service(state, tool) - if configured and provider_service: - print_kv("Model Provider Service", provider_service) - print_kv("Base URL", base_url) - if configured and tool in MCP_CLIENTS: + model_source = "managed" if agent_managed and models else model_freshness + if models: + rows.append((f"Models ({len(models)}, {model_source})", ", ".join(models))) + elif provider_service: + rows.append(("Models", "Defined by provider service")) + else: + rows.append((f"Models ({model_source})", "none available")) + default_model = _status_default_model(tool, effective_state, models) + if default_model: + rows.append(("Default model", default_model)) + if tool in ("claude", "codex"): + rows.append( + ( + "Tracing", + "enabled" if effective_state.get(f"{tool}_otel_tracing") else "disabled", + ) + ) + if tool in MCP_CLIENTS: # High-level overview: just a count per agent. `ug mcp list` (see the note below) shows # the per-server detail and live connection status, so status stays scannable. Dedupe by # name so a server present in both mcp_servers and managed_mcp_servers isn't double-counted. @@ -987,57 +1176,16 @@ def status() -> int: mcp_names |= claude_agent.read_managed_mcp_urls().keys() elif tool == "codex": mcp_names |= codex_agent.read_managed_mcp_urls().keys() - print_kv("MCP servers", str(len(mcp_names))) - print_kv("Config file", str(config_path) if config_path.exists() else "missing") - if tool == "claude": - managed_path, managed_status, backup_status = claude_agent.managed_settings_status( - state - ) - print_kv("OS-managed settings", managed_status) - print_kv("Managed settings file", str(managed_path) if managed_path else "unsupported") - print_kv("Managed settings backup", backup_status) - elif tool == "codex": - managed_path, managed_status, backup_status = codex_agent.managed_config_status(state) - print_kv("OS-managed settings", managed_status) - print_kv("Managed settings file", str(managed_path) if managed_path else "unsupported") - print_kv("Managed settings backup", backup_status) - console.print() - - print_heading("Skills") - skill_mcp_entry = next((s for s in mcp_servers if s.get("kind") == SKILLS_MCP_KIND), None) - if not skill_mcp_entry: - print_kv("Skills", "not configured") - else: - scopes = { - client: skill_locations_for_client(skill_mcp_entry, client) - for client in (skill_mcp_entry.get("clients") or []) - if client in MCP_CLIENTS - } - if agents_share_one_scope(scopes): - locations = next(iter(scopes.values()), []) - print_kv( - "Skill MCP Locations", - ", ".join(locations) if locations else "none — utility tools only", - ) - configured_agents = [str(MCP_CLIENTS[client]["display"]) for client in scopes] - print_kv("Configured", ", ".join(configured_agents) if configured_agents else "none") - else: - for client, locations in scopes.items(): - print_kv( - f"{MCP_CLIENTS[client]['display']} skill MCP locations", - ", ".join(locations) if locations else "none — utility tools only", - ) - - print_heading("State") - print_kv("State file", str(STATE_PATH) if STATE_PATH.exists() else "missing") - print_note("Use `ug configure` to update workspace settings or configure new tools.") - print_note("Use `ug mcp add` to add Databricks MCP servers to configured coding tools.") - print_note("Use `ug mcp list` to see configured MCP servers and their connection status.") - print_note( - "Use `ug configure skills` to set up Unity Catalog Skills for configured coding tools." - ) - print_note("Use `ug skills add` and `ug skills remove --mcp` to manage UC Skills.") - print_note("Use `ug revert` to clear managed configs and restore prior files.") + rows.append(("MCP servers", str(len(mcp_names)))) + rows.append(("Skills MCP", _status_skill_scope(state, tool))) + base_url = state.get("base_urls", {}).get(tool) + if isinstance(base_url, dict): + base_url = ", ".join(str(url) for url in base_url.values()) + rows.append(("Endpoint", str(base_url or "not configured"))) + _print_status_panel(str(spec["display"]), rows) + + if not configured_tools: + print_note("No coding agents are configured.") return 0 @@ -3465,7 +3613,7 @@ def export_cmd( @app.command("status", rich_help_panel="Manage") def status_cmd() -> None: - """Show current workspace, tool configs, and saved model selections.""" + """Show current workspace, tool configs, and live model availability.""" try: status() except RuntimeError as exc: diff --git a/tests/test_cli.py b/tests/test_cli.py index c8a7bef7..ff0a93d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1626,19 +1626,38 @@ def test_errors_without_workspace(self): class TestStatus: + @pytest.fixture(autouse=True) + def _live_model_state(self): + with ( + patch( + "ucode.cli._live_status_model_state", + side_effect=lambda state, _tools: (state, "live"), + ), + patch( + "ucode.cli._live_status_managed_state", + side_effect=lambda _state, cached: (cached, "live"), + ), + ): + yield + def test_points_to_ug_mcp_list_with_counts(self): # status is a high-level overview: it shows a per-agent MCP count and points to the # detail command, rather than surfacing each agent's raw ` mcp list` command. with patch("ucode.cli.load_state", return_value=MINIMAL_STATE): result = runner.invoke(app, ["status"]) + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output - assert "Managed by Databricks" not in result.output - assert "MCP servers: 0" in result.output - assert "ug mcp list" in result.output - assert "MCP list command:" not in result.output - assert "claude mcp list" not in result.output - assert "codex mcp list" not in result.output + assert "Configuration: Self-configured" in output + assert "MCP servers: 0" in output + assert "Details:" not in result.output + assert "Manage:" not in result.output + assert "Config file" not in result.output + assert "System settings" not in result.output + panel_tops = [ + line for line in _strip_ansi(result.output).splitlines() if line.startswith("╭") + ] + assert len({len(line) for line in panel_tops}) == 1 def test_shows_mcp_server_counts_configured_by_ucode(self): state = { @@ -1661,12 +1680,12 @@ def test_shows_mcp_server_counts_configured_by_ucode(self): with patch("ucode.cli.load_state", return_value=state): result = runner.invoke(app, ["status"]) + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output # Counts, not names: claude, codex, and gemini each carry one server. - assert "MCP servers: 1" in result.output + assert "MCP servers: 1" in output assert "github-mcp" not in result.output assert "databricks-sql" not in result.output - assert "ug mcp list" in result.output def test_mcp_count_includes_managed_servers_and_dedupes(self): # The count folds in workspace-managed servers (matching `ug mcp list`) and dedupes a @@ -1698,12 +1717,22 @@ def test_mcp_count_includes_managed_servers_and_dedupes(self): }, ], } - with patch("ucode.cli.load_state", return_value=state): + with ( + patch("ucode.cli.load_state", return_value=state), + patch( + "ucode.cli.claude_agent.read_managed_mcp_urls", + return_value={ + "managed-mcp": "https://example.databricks.com/managed-mcp", + "os-only-mcp": "https://example.databricks.com/os-only-mcp", + }, + ), + ): result = runner.invoke(app, ["status"]) + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output - # claude: dev-mcp, shared-mcp, managed-mcp = 3 distinct (shared-mcp not double-counted). - assert "MCP servers: 3" in result.output + # The managed file adds os-only-mcp; managed-mcp remains deduplicated by name. + assert "MCP servers: 4" in output def test_status_treats_available_tools_as_configured_agents(self): state = { @@ -1725,15 +1754,31 @@ def test_status_treats_available_tools_as_configured_agents(self): with patch("ucode.cli.load_state", return_value=state): result = runner.invoke(app, ["status"]) + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output - assert "MCP servers: 1" in result.output + assert "MCP servers: 1" in output + assert "GitHub Copilot CLI" in output + assert "Claude Code" not in output + assert "Gemini CLI" not in output assert "databricks-sql" not in result.output assert "https://example.databricks.com/ai-gateway/anthropic" not in result.output assert "https://example.databricks.com/ai-gateway/gemini" not in result.output - def test_status_shows_managed_config_box_when_present_and_enabled(self, monkeypatch): + def test_status_shows_effective_managed_models_and_tracing(self, monkeypatch): managed = { - "enabled_agents": {"claude": {}, "codex": {}}, + "enabled_agents": { + "claude": { + "model_config": { + "default_model": "system.ai.claude-opus-4-8", + "model_services": [ + "system.ai.claude-opus-4-8", + "system.ai.claude-sonnet-5", + ], + }, + "otel_tracing_enabled": True, + }, + "codex": {}, + }, "mcp_servers": [{"name": "github-mcp", "type": "external"}], "skills": {"names": ["debug-ci"]}, } @@ -1743,21 +1788,103 @@ def test_status_shows_managed_config_box_when_present_and_enabled(self, monkeypa ): result = runner.invoke(app, ["status"]) + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output - assert "Configuration" in result.output - assert "Coding Agents:" in result.output - assert "github-mcp" in result.output - assert "debug-ci" in result.output + assert "Configuration: Workspace-managed" in output + assert "Models (2, managed): system.ai.claude-opus-4-8, system.ai.claude-sonnet-5" in output + assert "Default model: system.ai.claude-opus-4-8" in output + assert "Tracing: enabled" in output - def test_status_hides_managed_config_box_when_none_present(self, monkeypatch): + def test_status_labels_self_configured_setup(self, monkeypatch): with ( patch("ucode.cli.load_state", return_value=MINIMAL_STATE), patch("ucode.cli.load_managed_state", return_value=None), ): result = runner.invoke(app, ["status"]) - + output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output - assert "Configuration" not in result.output + assert "Configuration: Self-configured" in output + assert "Models (1, live): codex-mini" in output + assert "Models (1, live): databricks-claude-sonnet-4" in output + assert cli_mod._status_default_model("claude", MINIMAL_STATE, ["cached"]) is None + assert "Tracing: disabled" in output + + +class TestStatusLiveModels: + def test_refreshes_models_with_the_saved_profile_without_persisting(self): + state = { + **MINIMAL_STATE, + "profile": "explicit-profile", + "claude_models": {"sonnet": "cached-claude"}, + "codex_models": ["cached-codex"], + } + with ( + patch("ucode.cli.get_databricks_token", return_value="token") as get_token, + patch( + "ucode.cli.discover_model_services", + return_value=( + {"opus": "live-claude"}, + ["live-codex"], + ["live-gemini"], + ["live-oss"], + None, + ), + ), + patch("ucode.cli.discover_claude_models") as legacy_claude, + patch("ucode.cli.discover_codex_models") as legacy_codex, + patch("ucode.cli.discover_gemini_models") as legacy_gemini, + patch("ucode.cli.save_state") as save, + ): + live, freshness = cli_mod._live_status_model_state(state, {"claude", "codex"}) + + assert freshness == "live" + assert live["claude_models"] == {"opus": "live-claude"} + assert live["codex_models"] == ["live-codex"] + assert live["opencode_models"]["oss"] == ["live-oss"] + assert state["codex_models"] == ["cached-codex"] + get_token.assert_called_once_with("https://example.databricks.com", "explicit-profile") + legacy_claude.assert_not_called() + legacy_codex.assert_not_called() + legacy_gemini.assert_not_called() + save.assert_not_called() + + def test_labels_cached_fallback_when_live_auth_fails(self): + state = {**MINIMAL_STATE, "profile": "explicit-profile"} + with patch("ucode.cli.get_databricks_token", side_effect=RuntimeError("expired login")): + resolved, freshness = cli_mod._live_status_model_state(state, {"claude"}) + + assert resolved is state + assert freshness == "cached" + + +class TestStatusLiveManagedConfig: + def test_refreshes_with_saved_profile_without_persisting(self): + state = {**MINIMAL_STATE, "profile": "explicit-profile"} + raw = {"enabled_agents": []} + normalized = {"enabled_agents": {"codex": {}}} + with ( + patch("ucode.cli.get_databricks_token", return_value="token") as get_token, + patch("ucode.cli.get_managed_config", return_value=(raw, None)) as fetch, + patch("ucode.cli.normalize_managed_config", return_value=normalized) as normalize, + patch("ucode.cli.save_state") as save, + ): + managed, freshness = cli_mod._live_status_managed_state(state, {"cached": True}) + + assert managed == normalized + assert freshness == "live" + get_token.assert_called_once_with("https://example.databricks.com", "explicit-profile") + fetch.assert_called_once_with("https://example.databricks.com", "token") + normalize.assert_called_once_with(raw) + save.assert_not_called() + + def test_labels_cached_fallback_when_live_auth_fails(self): + state = {**MINIMAL_STATE, "profile": "explicit-profile"} + cached = {"enabled_agents": {"claude": {}}} + with patch("ucode.cli.get_databricks_token", side_effect=RuntimeError("expired login")): + managed, freshness = cli_mod._live_status_managed_state(state, cached) + + assert managed is cached + assert freshness == "cached" class TestConfigureSkillsCommand: @@ -2349,15 +2476,18 @@ def test_failure_never_blocks_configure(self): class TestStatusSkillsSection: def _run(self, state): - with patch("ucode.cli.load_state", return_value=state): + with ( + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli._live_status_managed_state", return_value=(None, "live")), + patch("ucode.cli._live_status_model_state", return_value=(state, "live")), + ): return runner.invoke(app, ["status"]) def test_not_configured_when_no_skills_entry(self): result = self._run(MINIMAL_STATE) assert result.exit_code == 0, result.output - out = _strip_ansi(result.output) - assert "Skills" in out - assert "not configured" in out + out = re.sub(r"\s+", " ", _strip_ansi(result.output)) + assert "Skills MCP: not configured" in out def test_renders_locations_and_configured_agents(self): state = { @@ -2375,9 +2505,8 @@ def test_renders_locations_and_configured_agents(self): } result = self._run(state) assert result.exit_code == 0, result.output - out = _strip_ansi(result.output) - assert "Skill MCP Locations: main.default, ml.prod" in out - assert "Configured: Claude Code, Codex" in out + out = re.sub(r"\s+", " ", _strip_ansi(result.output)) + assert out.count("Skills MCP: main.default, ml.prod") == 2 def test_renders_placeholder_when_no_locations(self): state = { @@ -2395,8 +2524,8 @@ def test_renders_placeholder_when_no_locations(self): } result = self._run(state) assert result.exit_code == 0, result.output - out = _strip_ansi(result.output) - assert "Skill MCP Locations: none — utility tools only" in out + out = re.sub(r"\s+", " ", _strip_ansi(result.output)) + assert "Skills MCP: utility tools only" in out def test_skills_entry_absent_from_per_client_mcp_lines(self): state = { @@ -2421,12 +2550,13 @@ def test_skills_entry_absent_from_per_client_mcp_lines(self): result = self._run(state) assert result.exit_code == 0, result.output out = _strip_ansi(result.output) - # The skills registry is managed in the Skills section, never listed on - # a per-client "MCP servers:" line. + # The skills registry has its own row and is never counted as a general MCP server. for line in out.splitlines(): if "MCP servers:" in line: assert "databricks-skill-registry" not in line - assert "Skill MCP Locations: main.default" in out + flat = re.sub(r"\s+", " ", out) + assert "MCP servers: 1" in flat + assert "Skills MCP: main.default" in flat def test_renders_per_agent_locations_when_scopes_diverge(self): state = { @@ -2450,9 +2580,9 @@ def test_renders_per_agent_locations_when_scopes_diverge(self): result = self._run(state) assert result.exit_code == 0, result.output - out = _strip_ansi(result.output) - assert "Claude Code skill MCP locations: main.default, claude.only" in out - assert "Codex skill MCP locations: main.default" in out + out = re.sub(r"\s+", " ", _strip_ansi(result.output)) + assert "Skills MCP: main.default, claude.only" in out + assert "Skills MCP: main.default" in out class TestRevert: