Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-14 10:33:15 +02:00 committed by GitHub
parent e9a9217cc8
commit e1a25caa52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2830 additions and 594 deletions

View file

@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None:
case None:
rprint("\n[yellow]Setup cancelled. See you next time![/]")
sys.exit(0)
case str() as s if s.startswith("env_var_error:"):
env_key = s.removeprefix("env_var_error:")
rprint(
"\n[yellow]Could not save the API key because this provider is "
f"configured with an invalid environment variable name: {env_key}.[/]"
"\n[dim]The API key was not saved for this session. "
"Update the provider's `api_key_env_var` setting in your config and try again.[/]\n"
)
sys.exit(1)
case str() as s if s.startswith("save_error:"):
err = s.removeprefix("save_error:")
rprint(

View file

@ -0,0 +1,208 @@
from __future__ import annotations
from dataclasses import dataclass
import os
import tomllib
from typing import Any
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
)
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
_ONBOARDING_LIST_ADAPTER = TypeAdapter(list[Any])
def _default_provider_payloads() -> list[dict[str, Any]]:
return [provider.model_dump(mode="json") for provider in DEFAULT_PROVIDERS]
def _default_model_payloads() -> list[dict[str, Any]]:
return [model.model_dump(mode="json") for model in DEFAULT_MODELS]
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
_ONBOARDING_FIELDS = frozenset(_OnboardingSnapshot.model_fields)
def _can_resolve_provider_from_explicit_overrides(
explicit_overrides: dict[str, Any],
) -> bool:
return "providers" in explicit_overrides
def _find_env_value(name: str) -> str | None:
expected_name = name.upper()
for env_name, value in os.environ.items():
if env_name.upper() == expected_name:
return value
return None
def _load_onboarding_toml_payload() -> dict[str, Any]:
try:
harness_files = get_harness_files_manager()
except RuntimeError:
return {}
config_file = harness_files.config_file
if config_file is None:
return {}
try:
with config_file.open("rb") as file:
toml_data = tomllib.load(file)
except FileNotFoundError:
return {}
except tomllib.TOMLDecodeError as err:
raise RuntimeError(f"Invalid TOML in {config_file}: {err}") from err
except OSError as err:
raise RuntimeError(f"Cannot read {config_file}: {err}") from err
return {
field_name: toml_data[field_name]
for field_name in _ONBOARDING_FIELDS
if field_name in toml_data
}
def _load_onboarding_env_payload_for_fields(
field_names: frozenset[str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
if (
"active_model" in field_names
and (active_model := _find_env_value("VIBE_ACTIVE_MODEL")) is not None
):
payload["active_model"] = active_model
if (
"providers" in field_names
and (providers := _find_env_value("VIBE_PROVIDERS")) is not None
):
payload["providers"] = _ONBOARDING_LIST_ADAPTER.validate_json(providers)
if (
"models" in field_names
and (models := _find_env_value("VIBE_MODELS")) is not None
):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
return payload
def _explicit_onboarding_overrides(**overrides: Any) -> dict[str, Any]:
return {
field_name: value
for field_name, value in overrides.items()
if field_name in _ONBOARDING_FIELDS
}
def _build_onboarding_snapshot_payload(**overrides: Any) -> dict[str, Any]:
explicit_overrides = _explicit_onboarding_overrides(**overrides)
payload = _OnboardingSnapshot().model_dump()
if explicit_overrides.keys() >= _ONBOARDING_FIELDS:
payload.update(explicit_overrides)
return payload
try:
payload.update(_load_onboarding_toml_payload())
except RuntimeError:
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
raise
try:
payload.update(
_load_onboarding_env_payload_for_fields(
_ONBOARDING_FIELDS.difference(explicit_overrides)
)
)
except (ValidationError, ValueError):
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
raise
payload.update(explicit_overrides)
return payload
def _validated_payloads[PayloadConfig: ModelConfig | ProviderConfig](
payloads: list[Any], model_type: type[PayloadConfig]
) -> list[PayloadConfig]:
validated_payloads: list[PayloadConfig] = []
for payload in payloads:
if isinstance(payload, model_type):
validated_payloads.append(payload)
continue
if not isinstance(payload, dict):
continue
try:
validated_payloads.append(model_type.model_validate(payload))
except (ValidationError, ValueError):
continue
return validated_payloads
def _resolve_provider(
*, active_model: str, snapshot: _OnboardingSnapshot
) -> ProviderConfig:
providers_by_name: dict[str, ProviderConfig] = {}
for provider in _validated_payloads(snapshot.providers, ProviderConfig):
providers_by_name.setdefault(provider.name, provider)
models = _validated_payloads(snapshot.models, ModelConfig)
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL):
for model in models:
if model.alias != model_alias:
continue
if provider := providers_by_name.get(model.provider):
return provider
for model in models:
if provider := providers_by_name.get(model.provider):
return provider
if len(providers_by_name) == 1:
return next(iter(providers_by_name.values()))
return DEFAULT_PROVIDERS[0]
@dataclass(frozen=True)
class OnboardingContext:
provider: ProviderConfig
@property
def supports_browser_sign_in(self) -> bool:
return self.provider.supports_browser_sign_in
@classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext:
return cls(provider=config.get_provider_for_model(config.get_active_model()))
@classmethod
def load(cls, **overrides: Any) -> OnboardingContext:
try:
snapshot = _OnboardingSnapshot.model_validate(
_build_onboarding_snapshot_payload(**overrides)
)
return cls(
provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot
)
)
except (RuntimeError, ValidationError, ValueError):
logger.warning(
"Onboarding config fallback activated; using defaults", exc_info=True
)
return cls.from_config(VibeConfig.model_construct())

View file

@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.context import OnboardingContext
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
@ -32,6 +33,42 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def persist_api_key(provider: ProviderConfig, api_key: str) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(config_getter=VibeConfig)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _resolve_onboarding_provider(
provider: ProviderConfig | None = None,
) -> ProviderConfig:
resolved_provider = provider or OnboardingContext.load().provider
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False),
@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen):
NEXT_SCREEN = None
def __init__(self) -> None:
def __init__(self, provider: ProviderConfig | None = None) -> None:
super().__init__()
config = VibeConfig.model_construct()
active_model = config.get_active_model()
self.provider = config.get_provider_for_model(active_model)
self.provider = _resolve_onboarding_provider(provider)
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
if self.provider.name not in PROVIDER_HELP:
@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen):
self._save_and_finish(event.value)
def _save_and_finish(self, api_key: str) -> None:
env_key = self.provider.api_key_env_var
os.environ[env_key] = api_key
try:
_save_api_key_to_env_file(env_key, api_key)
except OSError as err:
self.app.exit(f"save_error:{err}")
return
if self.provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(config_getter=VibeConfig)
telemetry.send_onboarding_api_key_added()
except Exception:
pass # Telemetry is fire-and-forget; don't fail onboarding
self.app.exit("completed")
self.app.exit(persist_api_key(self.provider, api_key))
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self.app)

View file

@ -6,7 +6,7 @@ from typing import Any, ClassVar
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import CenterMiddle, Horizontal
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
from textual.message import Message
from textual.widgets import Static
@ -38,46 +38,77 @@ class TrustFolderDialog(CenterMiddle):
class Untrusted(Message):
pass
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.detected_files = detected_files
self.selected_option = 0
self.option_widgets: list[Static] = []
def _compose_scroll_content(self) -> ComposeResult:
why_content = (
"Files here can modify AI behavior. Malicious "
"configs may exfiltrate data, run destructive "
"commands, or silently alter your code."
)
with Center(classes="trust-dialog-section-center"):
yield NoMarkupStatic(
why_content,
id="trust-dialog-warning",
classes="trust-dialog-section-content",
)
if self.detected_files:
yield NoMarkupStatic(
"Detected configuration files\n", classes="trust-dialog-section-header"
)
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
with Center(classes="trust-dialog-section-center"):
yield NoMarkupStatic(
file_list,
id="trust-dialog-files",
classes="trust-dialog-section-content",
)
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic(
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
)
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
yield NoMarkupStatic(
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
id="trust-dialog-message",
classes="trust-dialog-message",
)
with CenterMiddle(id="trust-dialog-container"):
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
with Horizontal(id="trust-options-container"):
options = ["Yes", "No"]
for idx, text in enumerate(options):
widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option"
)
self.option_widgets.append(widget)
yield widget
with VerticalScroll(id="trust-dialog-content"):
yield from self._compose_scroll_content()
yield NoMarkupStatic(
"← → navigate Enter select", classes="trust-dialog-help"
)
yield NoMarkupStatic(
"Only trust folders you fully control",
id="trust-dialog-footer-warning",
classes="trust-dialog-footer-warning",
)
yield NoMarkupStatic(
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
id="trust-dialog-save-info",
classes="trust-dialog-save-info",
)
with Horizontal(id="trust-options-container"):
options = ["Yes, trust this folder", "No, ignore config files"]
for idx, text in enumerate(options):
widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option"
)
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic(
"← → navigate Enter select", classes="trust-dialog-help"
)
yield NoMarkupStatic(
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
id="trust-dialog-save-info",
classes="trust-dialog-save-info",
)
async def on_mount(self) -> None:
self.selected_option = 1 # Default to "No"
@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle):
self.focus()
def _update_options(self) -> None:
options = ["Yes", "No"]
options = ["Yes, trust this folder", "No, ignore config files"]
if len(self.option_widgets) != len(options):
return
@ -146,9 +177,12 @@ class TrustFolderApp(App):
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True),
]
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.detected_files = detected_files
self._result: bool | None = None
self._quit_without_saving = False
@ -156,7 +190,7 @@ class TrustFolderApp(App):
self.theme = "textual-ansi"
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path)
yield TrustFolderDialog(self.folder_path, self.detected_files)
def action_quit_without_saving(self) -> None:
self._quit_without_saving = True
@ -177,6 +211,6 @@ class TrustFolderApp(App):
return self._result
def ask_trust_folder(folder_path: Path) -> bool | None:
app = TrustFolderApp(folder_path)
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
app = TrustFolderApp(folder_path, detected_files)
return app.run_trust_dialog()

View file

@ -3,14 +3,25 @@ Screen {
background: transparent 80%;
}
#trust-dialog {
width: 70;
max-width: 90%;
min-width: 50;
height: auto;
max-width: 70;
overflow-y: scroll;
border: round ansi_bright_black;
background: transparent;
height:auto;
max-height: 1fr;
padding: 1;
padding-left:5;
padding-right:5;
scrollbar-size: 1 1;
}
#trust-dialog-content {
width: 100%;
height: auto;
min-height: 3;
max-height: 10;
}
#trust-dialog-title {
@ -31,15 +42,42 @@ Screen {
margin-bottom: 1;
}
#trust-dialog-message {
.trust-dialog-section-header {
width: 100%;
height: auto;
color: ansi_default;
text-align: center;
text-wrap: wrap;
padding: 0 2;
}
.trust-dialog-section-center {
width: 100%;
height: auto;
margin-bottom: 1;
}
.trust-dialog-section-content {
width: auto;
max-width: 100%;
height: auto;
color: ansi_default;
text-align: center;
text-overflow: fold;
}
.trust-dialog-footer-warning {
width: 100%;
height: auto;
color: ansi_yellow;
text-align: center;
text-style: bold;
border-top: solid ansi_bright_black;
border-bottom: solid ansi_bright_black;
margin-bottom: 1;
padding: 0 1;
}
#trust-options-container {
width: 100%;
height: auto;
@ -79,5 +117,4 @@ Screen {
text-align: center;
text-style: italic;
text-wrap: wrap;
margin-bottom: 1;
}