Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

639 lines
22 KiB
Python

"""UI primitives and rendering helpers for the wizard onboarding flow."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import cast
import questionary
from rich.console import Console
from rich.rule import Rule
from rich.text import Text
from config.llm_auth.auth_method import (
OAUTH_AUTH_METHOD,
canonical_llm_provider,
get_configured_llm_auth_method,
normalize_llm_auth_method,
)
from config.llm_auth.credentials import has_llm_api_key, save_api_key
from config.llm_auth.provider_catalog import API_KEY_PROVIDER_ENVS
from config.llm_credentials import get_keyring_setup_instructions, save_llm_api_key
from config.version import get_opensre_version
from integrations.store import get_integration
from platform.terminal.theme import (
BG,
BRAND,
DIM,
ERROR,
GLYPH_ERROR,
GLYPH_SUCCESS,
GLYPH_WARNING,
HIGHLIGHT,
SECONDARY,
TEXT,
WARNING,
)
from surfaces.cli.llm_auth.service import AuthSetupError, persist_api_key_secret
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE, ProviderOption
from surfaces.cli.wizard.integration_health import IntegrationHealthResult
from surfaces.cli.wizard.probes import ProbeResult
from surfaces.cli.wizard.prompts import select as select_prompt
from surfaces.cli.wizard.store import get_store_path, load_local_config
_console = Console(
highlight=False, force_terminal=True, color_system="truecolor", legacy_windows=False
)
def _questionary_style() -> questionary.Style:
"""Build questionary styles from the active terminal theme.
Highlighted list rows use ``BG`` (dark) on ``HIGHLIGHT`` (light accent) so
selected options stay readable across every palette — light ``TEXT`` on a
light ``HIGHLIGHT`` background was nearly invisible in green and similar themes.
"""
return questionary.Style(
[
("qmark", f"fg:{HIGHLIGHT} bold"),
("question", f"fg:{TEXT} bold"),
("answer", f"fg:{BRAND} bold"),
("pointer", f"fg:{HIGHLIGHT} bold"),
("highlighted", f"fg:{BG} bg:{HIGHLIGHT} bold"),
("selected", f"fg:{TEXT} bg:default bold"),
("group-header", f"fg:{HIGHLIGHT} bold"),
("separator", f"fg:{DIM}"),
("text", f"fg:{TEXT} bg:default"),
("disabled", f"fg:{SECONDARY} bg:default italic"),
("instruction", f"fg:{SECONDARY} italic"),
]
)
def _group_header_label(group: str) -> str:
"""Format a category label for grouped wizard pickers."""
return f"── {group} ──"
@dataclass(frozen=True)
class Choice:
"""A selectable wizard choice."""
value: str
label: str
group: str | None = None
hint: str | None = None
class WizardBack(KeyboardInterrupt):
"""Raised when a prompt-level cancel should move back one wizard step."""
def _as_mapping(value: object) -> Mapping[str, object]:
return value if isinstance(value, Mapping) else {}
def _string_value(value: object, fallback: str = "") -> str:
return value if isinstance(value, str) else fallback
def _joined_values(value: object, *, separator: str, fallback: str) -> str:
if isinstance(value, str):
return value
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return separator.join(value)
return fallback
def _local_defaults() -> dict[str, str | bool | None]:
stored = load_local_config(get_store_path())
wizard = _as_mapping(stored.get("wizard"))
targets = _as_mapping(stored.get("targets"))
local = _as_mapping(targets.get("local"))
raw_provider = local.get("provider")
raw_provider_value = _string_value(raw_provider) if raw_provider else ""
provider_value = canonical_llm_provider(raw_provider_value) if raw_provider_value else ""
provider = PROVIDER_BY_VALUE.get(provider_value) if provider_value else None
raw_provider_option = PROVIDER_BY_VALUE.get(raw_provider_value) if raw_provider_value else None
api_key_provider = raw_provider_option or provider
api_key_env = _string_value(
local.get("api_key_env"), api_key_provider.api_key_env if api_key_provider else ""
)
is_cli = bool(raw_provider_option and raw_provider_option.credential_kind == "cli")
is_oauth_backend = bool(raw_provider_value and raw_provider_value != provider_value)
raw_auth_method = local.get("auth_method")
auth_method = (
normalize_llm_auth_method(raw_auth_method if isinstance(raw_auth_method, str) else None)
if raw_auth_method
else get_configured_llm_auth_method(_string_value(raw_provider))
)
if is_oauth_backend:
auth_method = OAUTH_AUTH_METHOD
return {
"wizard_mode": _string_value(wizard.get("mode"), "quickstart"),
"provider": provider_value if raw_provider_value else None,
"auth_method": auth_method,
"model": _string_value(local.get("model")),
"api_key_env": api_key_env,
"has_api_key": True if is_cli else bool(api_key_env and has_llm_api_key(api_key_env)),
"legacy_api_key": _string_value(local.get("api_key")),
}
def _integration_defaults(service: str) -> tuple[Mapping[str, object], Mapping[str, object]]:
entry = _as_mapping(get_integration(service))
return entry, _as_mapping(entry.get("credentials"))
def _step(title: str) -> None:
_console.print()
t = Text()
t.append(" ")
t.append(title, style=f"bold {HIGHLIGHT}")
_console.print(t)
_console.print(Rule(style=DIM))
def _step_header(n: int, total: int, title: str) -> None:
"""Print a numbered wizard stage header.
Rendered output (colour roles):
───────────────────────────────────────── [DIM rule]
●●○○ LLM Provider 2/4 [BRAND dots] [TEXT title] [SECONDARY counter]
───────────────────────────────────────── [DIM rule]
"""
dots = "●" * n + "○" * (total - n)
_console.print()
_console.print(Rule(style=DIM))
header = Text()
header.append(" ")
header.append(dots, style=f"bold {BRAND}")
header.append(" ", style=DIM)
header.append(title, style=f"bold {TEXT}")
header.append(f" {n}/{total}", style=SECONDARY)
_console.print(header)
_console.print(Rule(style=DIM))
def _choice_title(choice: Choice) -> str:
return choice.label
def _choice_description(choice: Choice) -> str | None:
if choice.hint:
return choice.hint
return choice.group
def _questionary_choice(choice: Choice) -> questionary.Choice:
return questionary.Choice(
title=_choice_title(choice),
value=choice.value,
description=_choice_description(choice),
)
def _grouped_questionary_choices(
choices: list[Choice],
*,
group_order: tuple[str, ...],
trailing_choices: list[Choice] | None = None,
) -> list[questionary.Choice | questionary.Separator]:
"""Render selectable choices with non-selectable category separators."""
grouped: dict[str, list[Choice]] = {group: [] for group in group_order}
ungrouped: list[Choice] = []
for choice in choices:
if choice.group is None or choice.group not in grouped:
ungrouped.append(choice)
continue
grouped[choice.group].append(choice)
rendered: list[questionary.Choice | questionary.Separator] = []
for group in group_order:
group_choices = grouped[group]
if not group_choices:
continue
rendered.append(questionary.Separator(_group_header_label(group)))
rendered.extend(_questionary_choice(choice) for choice in group_choices)
if ungrouped:
rendered.append(questionary.Separator(_group_header_label("Other")))
rendered.extend(_questionary_choice(choice) for choice in ungrouped)
if trailing_choices:
rendered.append(questionary.Separator())
rendered.extend(_questionary_choice(choice) for choice in trailing_choices)
return rendered
_CUSTOM_MODEL_SENTINEL = "__custom__"
def _provider_model_prompt_label(provider: ProviderOption) -> str:
"""Provider label without auth-method suffixes that read badly in model prompts."""
for suffix in (" API key", " OAuth"):
if provider.label.endswith(suffix):
return provider.label[: -len(suffix)]
return provider.label
def _choose_model(
provider: ProviderOption,
*,
default: str | None,
prompt_label: str | None = None,
back_on_cancel: bool = False,
) -> str:
"""Prompt the user to pick a model from ``provider.models``.
Choices come from the curated config in ``surfaces/cli/wizard/config.py``.
A saved model that isn't in the curated list is preserved as ``current``
so re-running the wizard never silently drops a user's prior pick, and an
"Enter custom model ID" escape hatch is always available.
"""
resolved_default = (default or "").strip()
models = provider.models
if not models:
return resolved_default or provider.default_model
_step("Model")
curated_values = {option.value for option in models}
curated_choices: list[Choice] = [
Choice(value=option.value, label=option.label) for option in models
]
extra_choices: list[Choice] = []
if resolved_default and resolved_default not in curated_values:
extra_choices.append(Choice(value=resolved_default, label=resolved_default, hint="current"))
custom_choice = Choice(
value=_CUSTOM_MODEL_SENTINEL,
label="Enter custom model ID",
hint="type any model identifier",
)
choices = curated_choices + extra_choices + [custom_choice]
default_value = resolved_default or provider.default_model
if default_value and not any(c.value == default_value for c in choices):
default_value = curated_choices[0].value if curated_choices else _CUSTOM_MODEL_SENTINEL
provider_label = prompt_label or _provider_model_prompt_label(provider)
selection = _choose(
f"Choose {provider_label} model",
choices,
default=default_value or None,
back_on_cancel=back_on_cancel,
)
if selection != _CUSTOM_MODEL_SENTINEL:
return selection
return _prompt_value(
f"Custom {provider_label} model ID ({provider.model_env})",
default=resolved_default,
allow_empty=False,
back_on_cancel=back_on_cancel,
)
def _choose(
prompt: str,
choices: list[Choice],
*,
default: str | None = None,
group_order: tuple[str, ...] | None = None,
trailing_choices: list[Choice] | None = None,
back_on_cancel: bool = False,
) -> str:
if group_order is not None:
q_choices = _grouped_questionary_choices(
choices,
group_order=group_order,
trailing_choices=trailing_choices,
)
else:
q_choices = [_questionary_choice(choice) for choice in choices]
if trailing_choices:
q_choices.append(questionary.Separator())
q_choices.extend(_questionary_choice(choice) for choice in trailing_choices)
result = select_prompt(
prompt,
choices=q_choices,
default=default,
style=_questionary_style(),
instruction="(Use arrows to move, Enter to choose)",
).ask()
if result is None:
if back_on_cancel:
raise WizardBack
raise KeyboardInterrupt
return str(result)
def _confirm(prompt: str, *, default: bool = True) -> bool:
result = questionary.confirm(prompt, default=default, style=_questionary_style()).ask()
if result is None:
raise KeyboardInterrupt
return bool(result)
def _prompt_value(
label: str,
*,
default: str = "",
secret: bool = False,
allow_empty: bool = False,
back_on_cancel: bool = False,
) -> str:
while True:
instruction = "(Enter to keep current)" if default else None
if secret:
result = questionary.password(
label,
default=default,
style=_questionary_style(),
instruction=instruction,
).ask()
else:
result = questionary.text(
label,
default=default,
style=_questionary_style(),
instruction=instruction,
).ask()
if result is None:
if back_on_cancel:
raise WizardBack
raise KeyboardInterrupt
value = str(result).strip()
if value:
return value
if default:
return default
if allow_empty:
return ""
_console.print(f"[{ERROR}] {GLYPH_ERROR} Required.[/]")
def _persist_llm_api_key(env_var: str, value: str) -> bool:
try:
provider = next(
(
name
for name, provider_env in API_KEY_PROVIDER_ENVS.items()
if provider_env == env_var
),
"",
)
if provider:
save_api_key(provider, value)
else:
persist_api_key_secret(env_var, value, save_secret=save_llm_api_key)
except (AuthSetupError, RuntimeError, ValueError) as exc:
_console.print(f"[{ERROR}] {GLYPH_ERROR} {exc}[/]")
_console.print(
f"[{WARNING}] {GLYPH_WARNING} OpenSRE could not save your API key to the local system keychain.[/]"
)
for line in get_keyring_setup_instructions(env_var):
_console.print(f"[{SECONDARY}] {line}[/]")
return False
return True
def _parse_csv_values(raw_value: str) -> list[str]:
return [part.strip() for part in raw_value.split(",") if part.strip()]
def _display_probe(result: ProbeResult) -> None:
status = f"[{HIGHLIGHT}]reachable[/]" if result.reachable else f"[{ERROR}]unreachable[/]"
_console.print(f"{result.target}: {status} [{SECONDARY}]({result.detail})[/]")
def _select_target_for_advanced(local_probe: ProbeResult, remote_probe: ProbeResult) -> str | None:
_console.print(f"\n[{SECONDARY}]reachability[/]")
_display_probe(local_probe)
_display_probe(remote_probe)
target = _choose(
"Choose a configuration target:",
[
Choice(value="local", label="Local machine"),
Choice(value="remote", label="Remote target (future support)"),
],
default="local",
)
if target == "local":
return "local"
_console.print(f"\n[{WARNING}]Remote setup is not available yet.[/]")
if _confirm("Use local setup instead?", default=True):
return "local"
_console.print(f"[{WARNING}]Setup cancelled.[/]")
return None
def _render_header() -> None:
"""Print the onboarding splash using the design-system palette.
Rendered output (colour roles):
───────────────────────────────────────── [DIM rule]
___ ____ ____ _____ [HIGHLIGHT art]
/ _ \\ ...
opensre · v<version> [SECONDARY name] [DIM ·] [BRAND version]
open-source SRE agent for automated … [SECONDARY description]
───────────────────────────────────────── [DIM rule]
Setup — Configure your local AI stack … [SECONDARY subtitle]
"""
from surfaces.interactive_shell.ui.components.banner_art import _render_art
art = _render_art()
version = get_opensre_version()
_console.print()
_console.print(Rule(style=DIM))
_console.print()
for line in art.splitlines():
t = Text()
t.append(" ")
t.append(line, style=f"bold {HIGHLIGHT}")
_console.print(t)
_console.print()
subtitle = Text()
subtitle.append(" ")
subtitle.append("opensre", style=SECONDARY)
subtitle.append(" · ", style=DIM)
subtitle.append(f"v{version}", style=BRAND)
_console.print(subtitle)
desc = Text()
desc.append(
" open-source SRE agent for automated incident investigation and root cause analysis",
style=SECONDARY,
)
_console.print(desc)
_console.print()
_console.print(Rule(style=DIM))
_console.print()
setup_line = Text()
setup_line.append(" Setup", style=f"bold {TEXT}")
setup_line.append(
" — Configure your local AI stack and optional integrations.", style=SECONDARY
)
_console.print(setup_line)
_console.print()
def _render_saved_summary(
*,
provider_label: str,
model: str,
saved_path: str,
env_path: str,
configured_integrations: list[str],
credential_line: str = "system keychain",
) -> None:
"""Print the post-onboarding success screen.
Rendered output (colour roles):
───────────────────────────────────────── [DIM rule]
✓ Done. [HIGHLIGHT ✓ + text]
───────────────────────────────────────── [DIM rule]
[blank]
provider Anthropic [SECONDARY key] [TEXT value]
model claude-opus-4-5 [SECONDARY key] [TEXT value]
services grafana · datadog [SECONDARY key] [TEXT value]
config ~/.opensre/opensre.json [SECONDARY key] [BRAND path]
env .env [SECONDARY key] [BRAND path]
credentials system keychain [SECONDARY key] [TEXT value]
store ~/.opensre/store.json [SECONDARY key] [BRAND path]
"""
from integrations.store import STORE_PATH
integrations_str = " · ".join(configured_integrations) if configured_integrations else "none"
_console.print()
_console.print(Rule(style=DIM))
done = Text()
done.append(f" {GLYPH_SUCCESS} ", style=f"bold {HIGHLIGHT}")
done.append("Done.", style=f"bold {TEXT}")
_console.print(done)
_console.print(Rule(style=DIM))
_console.print()
key_col = 14
def _kv(key: str, value: str, value_style: str = TEXT) -> None:
row = Text()
row.append(f" {key:<{key_col}}", style=SECONDARY)
row.append(value, style=value_style)
_console.print(row)
_kv("provider", provider_label)
_kv("model", model)
_kv("services", integrations_str)
_kv("config", saved_path, BRAND)
_kv("env", env_path, BRAND)
_kv("credentials", credential_line)
_kv("store", str(STORE_PATH), BRAND)
_console.print()
def _render_integration_result(
service_label: str,
result: IntegrationHealthResult,
*,
github_display_level: str | None = None,
) -> None:
if result.github_mcp is not None:
from integrations.github.mcp import (
GitHubMcpDisplayDetailLevel,
print_github_mcp_validation_report,
)
print_github_mcp_validation_report(
result.github_mcp,
console=_console,
detail_level=cast(
GitHubMcpDisplayDetailLevel,
github_display_level or "standard",
),
)
return
ok = bool(result.ok)
detail = str(result.detail)
glyph = GLYPH_SUCCESS if ok else GLYPH_ERROR
glyph_style = f"bold {HIGHLIGHT}" if ok else f"bold {ERROR}"
prefix = "Connected" if ok else "Failed"
status_line = Text()
status_line.append(f" {glyph} ", style=glyph_style)
status_line.append(f"{service_label}", style=f"bold {TEXT}")
status_line.append(" · ", style=DIM)
status_line.append(prefix, style=TEXT)
_console.print(status_line)
for raw_line in detail.splitlines():
line = raw_line.strip()
if line:
detail_text = Text()
detail_text.append(f" {line}", style=SECONDARY)
_console.print(detail_text)
def _render_next_steps() -> None:
"""Print the 'What's next' section after successful onboarding.
Rendered output (colour roles):
───────────────────────────────────────── [DIM rule]
What's next [SECONDARY label]
───────────────────────────────────────── [DIM rule]
opensre [BRAND runnable command]
Start the interactive agent
opensre investigate -i alert.json [BRAND runnable command]
Run root-cause analysis on an alert
opensre doctor [BRAND runnable command]
Verify your environment setup
"""
_console.print(Rule(style=DIM))
section = Text()
section.append(" What's next", style=SECONDARY)
_console.print(section)
_console.print(Rule(style=DIM))
_console.print()
_NEXT: tuple[tuple[str, str], ...] = (
("opensre", "Start the interactive agent"),
(
"opensre investigate -i tests/e2e/kubernetes/fixtures/datadog_k8s_alert.json",
"Run root-cause analysis on a sample alert",
),
("opensre doctor", "Verify your full environment setup"),
("opensre onboard", "Re-run this setup at any time"),
)
for cmd, description in _NEXT:
cmd_line = Text()
cmd_line.append(f" {cmd}", style=f"bold {BRAND}")
_console.print(cmd_line)
desc_line = Text()
desc_line.append(f" {description}", style=SECONDARY)
_console.print(desc_line)
_console.print()