Co-authored-by: Charles-Edouard Cady <charles.edouard.cady@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Sylvain Afchain <safchain@gmail.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-29 17:14:25 +02:00 committed by GitHub
parent e607ccbb00
commit ed5b7192e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1456 additions and 125 deletions

View file

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.18.1] - 2026-06-26
### Added
- `/mcp add` slash command for adding OAuth MCP servers
- Petit chat animation idle pauses
### Changed
- Bare exit synonyms (exit, quit, :q, :quit) now treated as slash commands instead of prompts
### Fixed
- MCP OAuth login crash when keyring backend is un-loadable
## [2.18.0] - 2026-06-25
### Added

View file

@ -542,6 +542,20 @@ Notes:
You can configure MCP (Model Context Protocol) servers to extend Vibe's capabilities. Add MCP server configurations under the `mcp_servers` section:
For hosted OAuth MCP servers, you can add the server from inside Vibe:
```text
/mcp add https://mcp.linear.app/mcp
/mcp add https://mcp.example.com/mcp --name docs --scope read --transport http --no-login
```
`/mcp add` is OAuth-only. It writes `auth.type = "oauth"` with optional
scopes and starts login by default. It uses `transport = "streamable-http"`
unless you pass `--transport http`. Pass `--no-login` to add the server without
starting OAuth login. The shortcut supports `streamable-http` and `http`
transports. For API-key/static auth, edit `config.toml` using the static auth
example below.
```toml
# Example MCP server configurations
[[mcp_servers]]
@ -586,6 +600,20 @@ Key fields:
- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
- `env`: Environment variables to set for the MCP server of transport type stdio
HTTP MCP servers can use either static auth or OAuth. Static auth uses
`api_key_env` / `headers` in `config.toml`; OAuth uses an `auth` block:
```toml
[[mcp_servers]]
name = "linear"
transport = "streamable-http"
url = "https://mcp.linear.app/mcp"
[mcp_servers.auth]
type = "oauth"
scopes = []
```
MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools:
```toml

View file

@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.18.0"
version = "2.18.1"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-aarch64-2.18.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-aarch64-2.18.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-x86_64-2.18.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-x86_64-2.18.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-aarch64-2.18.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-aarch64-2.18.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-x86_64-2.18.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-x86_64-2.18.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-windows-x86_64-2.18.0.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-windows-x86_64-2.18.1.zip"
cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.18.0"
version = "2.18.1"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@ -215,7 +215,6 @@ select = [
"PLR",
"B0",
"B905",
"DOC102",
"RUF022",
"RUF010",
"RUF012",

View file

@ -72,7 +72,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1"
)
assert response.auth_methods is not None
@ -172,7 +172,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1"
)
assert response.auth_methods is not None

View file

@ -0,0 +1,26 @@
from __future__ import annotations
import pytest
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.widgets.chat_input.input_kinds import (
Prompt,
SlashCommand,
classify,
)
def _classify(value: str) -> object:
return classify(value, commands=CommandRegistry(), expand_skill=lambda _value: None)
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
def test_classify_treats_bare_exit_synonyms_as_slash_command(alias: str) -> None:
assert isinstance(_classify(alias), SlashCommand)
@pytest.mark.parametrize("value", ["exit the function early", "quit your job"])
def test_classify_keeps_bare_synonym_with_trailing_text_as_prompt(value: str) -> None:
result = _classify(value)
assert isinstance(result, Prompt)
assert result.text == value

View file

@ -0,0 +1,212 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.mcp_commands import parse_mcp_add_args, parse_mcp_subcommand
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
def _capture_mounted_widgets(
app: VibeApp, monkeypatch: pytest.MonkeyPatch
) -> list[object]:
mounted_widgets: list[object] = []
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
return mounted_widgets
def test_parse_mcp_add_args_accepts_url_and_options() -> None:
args = parse_mcp_add_args(
"https://mcp.example.com/mcp --name docs --scope read --scope write "
"--transport http --no-login"
)
assert args.url == "https://mcp.example.com/mcp"
assert args.name == "docs"
assert args.scopes == ["read", "write"]
assert args.transport == "http"
assert args.login is False
def test_parse_mcp_add_args_defaults_to_login() -> None:
args = parse_mcp_add_args("https://mcp.example.com/mcp")
assert args.transport == "streamable-http"
assert args.login is True
@pytest.mark.parametrize(
"raw_args",
[
"",
"https://mcp.example.com/mcp extra",
"https://mcp.example.com/mcp --unknown",
"https://mcp.example.com/mcp --name",
"https://mcp.example.com/mcp --name a --name b",
"https://mcp.example.com/mcp --scope",
"https://mcp.example.com/mcp --login",
"https://mcp.example.com/mcp --transport",
"https://mcp.example.com/mcp --transport sse",
"https://mcp.example.com/mcp --transport http --transport streamable-http",
"'unterminated",
],
)
def test_parse_mcp_add_args_rejects_invalid_args(raw_args: str) -> None:
with pytest.raises(ValueError):
parse_mcp_add_args(raw_args)
def test_parse_mcp_subcommand_recognizes_supported_subcommands() -> None:
parsed = parse_mcp_subcommand("add https://mcp.linear.app/mcp --no-login")
assert parsed is not None
assert parsed.name == "add"
assert parsed.args == "https://mcp.linear.app/mcp --no-login"
def test_parse_mcp_subcommand_ignores_unknown_subcommands() -> None:
assert parse_mcp_subcommand("tools linear") is None
@pytest.mark.asyncio
async def test_mcp_add_saves_oauth_server_and_prints_next_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "linear"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == []
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
and "/mcp login linear" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_name_and_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add(
"https://mcp.example.com/mcp --name docs --scope read --scope write --no-login"
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "docs"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == ["read", "write"]
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `docs`." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_http_transport(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --transport http --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPHttp)
assert server.transport == "http"
assert isinstance(server.auth, MCPOAuth)
@pytest.mark.asyncio
async def test_mcp_add_delegates_to_login_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp")
login.assert_awaited_once_with("linear")
assert any(
isinstance(widget, UserCommandMessage)
and "Starting OAuth login..." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_no_login_skips_login(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
login.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_add_mounts_parser_errors(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --unknown")
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Unknown /mcp add option: --unknown"
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_help_states_oauth_only(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("--help")
assert any(
isinstance(widget, UserCommandMessage)
and "Usage: /mcp add <url>" in widget._content
and "--transport <http|streamable-http>" in widget._content
and "OAuth-only shortcut" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_subcommand_handler_recognizes_add(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
handled = await app._maybe_handle_mcp_subcommand(
"add https://mcp.linear.app/mcp --no-login"
)
assert handled is True
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
for widget in mounted_widgets
)

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import SlashCommandMessage, UserMessage
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
@pytest.mark.asyncio
async def test_exit_synonym_runs_exit_handler_and_is_not_sent_as_prompt(
alias: str, monkeypatch: pytest.MonkeyPatch
) -> None:
vibe_app = build_test_vibe_app()
async with vibe_app.run_test() as pilot:
await pilot.pause(0.1)
calls: list[str] = []
async def _record_exit(**_kwargs: Any) -> None:
calls.append(alias)
monkeypatch.setattr(vibe_app, "_exit_app", _record_exit)
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.post_message(ChatInputContainer.Submitted(alias))
await pilot.pause(0.2)
assert calls == [alias]
prompts = [
m
for m in vibe_app.query(UserMessage)
if not isinstance(m, SlashCommandMessage)
]
assert prompts == []

View file

@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import signal
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual.app import WINDOWS
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
@ -249,3 +252,35 @@ async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
await _run_app_with_cleanup(app)
cleanup.assert_awaited_once()
def test_force_quit_delegates_to_private(app: VibeApp) -> None:
with patch.object(app, "_force_quit") as private:
app._force_quit()
private.assert_called_once_with()
@pytest.mark.skipif(WINDOWS, reason="SIGTERM handlers are not installed on Windows")
@pytest.mark.asyncio
async def test_run_app_with_cleanup_sigterm_triggers_force_quit(app: VibeApp) -> None:
captured: list[Callable[[], None]] = []
loop = asyncio.get_running_loop()
def capture_add(sig: int, callback: Callable[[], None], *args: object) -> None:
captured.append(callback)
async def fake_run_async() -> None:
captured[0]()
with (
patch.object(loop, "add_signal_handler", side_effect=capture_add),
patch.object(loop, "remove_signal_handler") as remove_handler,
patch.object(app, "run_async", side_effect=fake_run_async),
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock),
patch.object(app, "_force_quit") as force_quit,
):
await _run_app_with_cleanup(app)
force_quit.assert_called_once_with()
remove_handler.assert_any_call(signal.SIGTERM)

View file

@ -18,8 +18,14 @@ from vibe.core.config.layer import (
RawConfig,
)
from vibe.core.config.layers.environment import EnvironmentLayer
from vibe.core.config.layers.overrides import OverridesLayer
from vibe.core.config.layers.project import ProjectConfigLayer
from vibe.core.config.layers.user import UserConfigLayer
from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError
from vibe.core.config.orchestrator import (
ConfigOrchestrator,
ConfigPatchValidationError,
DefaultLayerResolutionError,
)
from vibe.core.config.patch import (
AddOperationPatch,
RemoveOperationPatch,
@ -36,6 +42,7 @@ from vibe.core.config.types import (
ConfigChangeEvent,
LayerConfigSnapshot,
)
from vibe.core.trusted_folders import trusted_folders_manager
class FakeLayer(ConfigLayer[RawConfig]):
@ -143,6 +150,12 @@ class RoutingSchema(ConfigSchema):
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
class CliRoutingSchema(ConfigSchema):
active_model: Annotated[str, WithReplaceMerge()] = "default-model"
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list)
class RequiredPairSchema(ConfigSchema):
first: Annotated[str, WithReplaceMerge()]
second: Annotated[str, WithReplaceMerge()]
@ -157,23 +170,33 @@ def assert_single_failure[E: BaseException](
return failure
def unused_default_layer() -> ConfigLayer[RawConfig]:
return FakeLayer(name="unused-default", data={})
@pytest.mark.asyncio
async def test_create_builds_config() -> None:
layer = FakeLayer(name="test", data={"value": "hello"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.config.model_dump() == {"value": "hello"}
@pytest.mark.asyncio
async def test_get_layer_returns_named_layer() -> None:
layer = FakeLayer(name="my-layer", data={})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.get_layer("my-layer") is layer
@pytest.mark.asyncio
async def test_get_layer_unknown_raises() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
with pytest.raises(KeyError, match="No layer named 'unknown'"):
orch.get_layer("unknown")
@ -181,7 +204,9 @@ async def test_get_layer_unknown_raises() -> None:
@pytest.mark.asyncio
async def test_reload_picks_up_changes() -> None:
layer = FakeLayer(name="mutable", data={"value": "original"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orch.config.value == "original"
layer._data = {"value": "updated"}
@ -192,21 +217,27 @@ async def test_reload_picks_up_changes() -> None:
@pytest.mark.asyncio
async def test_config_is_immutable() -> None:
layer = FakeLayer(name="test", data={"value": "hello"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
with pytest.raises(ValidationError, match="frozen"):
orch.config.value = "changed" # type: ignore[misc]
@pytest.mark.asyncio
async def test_origin_of_missing_key_returns_none() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
assert orch.config.origin_of("nonexistent") is None
@pytest.mark.asyncio
async def test_apply_patch_empty_operations_is_noop() -> None:
layer = FakeLayer(name="test", data={"value": "hello"})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch([], reason="no-op")
@ -216,7 +247,9 @@ async def test_apply_patch_empty_operations_is_noop() -> None:
@pytest.mark.asyncio
async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None:
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer
)
with pytest.raises(ConfigPatchValidationError) as exc_info:
await orch.apply_patch(
[ReplaceOperationPatch(path="/value", value={"invalid": "shape"})],
@ -229,25 +262,12 @@ async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> Non
assert isinstance(exc_info.value.__cause__, ValidationError)
@pytest.mark.asyncio
async def test_apply_patch_returns_failure_when_default_fallback_layer_is_missing() -> (
None
):
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[])
result = await orch.apply_patch(
[ReplaceOperationPatch(path="/value", value="updated")], reason="test update"
)
failure = assert_single_failure(result, KeyError)
assert str(failure) == f'"No layer named {UserConfigLayer.LAYER_NAME!r}"'
assert orch.config.value == "default"
@pytest.mark.asyncio
async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[
@ -265,11 +285,11 @@ async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
@pytest.mark.asyncio
async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> (
None
):
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
async def test_apply_patch_uses_default_layer_resolver_and_reloads_config() -> None:
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[AddOperationPatch(path="/value", value="updated")], reason="test update"
@ -280,6 +300,19 @@ async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config()
assert layer._data == {"value": "updated-normalized"}
@pytest.mark.asyncio
async def test_set_field_uses_default_layer_resolver() -> None:
layer = NormalizingWritableLayer(name="user-toml", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.set_field("/value", "updated", reason="set field")
assert result == []
assert orch.config.value == "updated-normalized"
@pytest.mark.asyncio
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
first_layer = FieldWritableLayer(
@ -287,7 +320,9 @@ async def test_apply_patch_returns_layer_save_error_after_other_layer_commits()
)
second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"})
orch = await ConfigOrchestrator.create(
schema=MultiValueSchema, layers=[first_layer, second_layer]
schema=MultiValueSchema,
layers=[first_layer, second_layer],
default_layer_resolver=lambda: first_layer,
)
result = await orch.apply_patch(
@ -325,7 +360,9 @@ async def test_apply_patch_returns_layer_apply_error_in_failures(
error: Exception,
) -> None:
layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error)
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
)
result = await orch.apply_patch(
[
@ -344,7 +381,9 @@ async def test_apply_patch_returns_unloaded_layer_error_in_failures() -> None:
loaded_layer = FakeLayer(name="loaded", data={"value": "hello"})
target_layer = FakeLayer(name="target", data={"value": "hello"})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema, layers=[loaded_layer, target_layer]
schema=SimpleSchema,
layers=[loaded_layer, target_layer],
default_layer_resolver=lambda: loaded_layer,
)
await target_layer.invalidate_cache()
@ -374,7 +413,9 @@ async def test_apply_patch_applies_layers_in_parallel() -> None:
barrier=barrier,
)
orch = await ConfigOrchestrator.create(
schema=MultiValueSchema, layers=[first_layer, second_layer]
schema=MultiValueSchema,
layers=[first_layer, second_layer],
default_layer_resolver=lambda: first_layer,
)
result = await orch.apply_patch(
@ -412,7 +453,11 @@ deprecated_setting = true
)
user_layer = UserConfigLayer(path=toml_path)
orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer])
orch = await ConfigOrchestrator.create(
schema=ToolSchema,
layers=[user_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
[
@ -437,22 +482,26 @@ deprecated_setting = true
@pytest.mark.asyncio
async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing(
async def test_apply_patch_creates_user_file_when_it_is_missing(
tmp_working_directory: Path,
) -> None:
toml_path = tmp_working_directory / "config.toml"
user_layer = UserConfigLayer(path=toml_path)
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[user_layer])
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[user_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
[AddOperationPatch(path="/value", value="created-later")],
reason="fallback write without user file",
)
failure = assert_single_failure(result, LayerNotLoadedError)
assert str(failure) == "Layer 'user-toml' must be loaded before applying patches"
assert not toml_path.exists()
assert orch.config.value == "default"
assert result == []
with toml_path.open("rb") as file:
assert tomllib.load(file) == {"value": "created-later"}
assert orch.config.value == "created-later"
@pytest.mark.asyncio
@ -466,7 +515,9 @@ async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is
user_layer = UserConfigLayer(path=toml_path)
environment_layer = EnvironmentLayer(schema=RoutingSchema)
orch = await ConfigOrchestrator.create(
schema=RoutingSchema, layers=[user_layer, environment_layer]
schema=RoutingSchema,
layers=[user_layer, environment_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
@ -498,7 +549,9 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer(
user_layer = UserConfigLayer(path=toml_path)
environment_layer = EnvironmentLayer(schema=RoutingSchema)
orch = await ConfigOrchestrator.create(
schema=RoutingSchema, layers=[user_layer, environment_layer]
schema=RoutingSchema,
layers=[user_layer, environment_layer],
default_layer_resolver=lambda: user_layer,
)
result = await orch.apply_patch(
@ -521,10 +574,117 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer(
assert orch.config.default_agent == "accept-edits"
@pytest.mark.asyncio
async def test_apply_patch_returns_failure_when_resolver_returns_unknown_layer() -> (
None
):
loaded_layer = RawWritableLayer(name="loaded", data={})
unknown_layer = RawWritableLayer(name="unknown", data={})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[loaded_layer],
default_layer_resolver=lambda: unknown_layer,
)
with pytest.raises(DefaultLayerResolutionError, match="unknown layer 'unknown'"):
await orch.apply_patch(
[AddOperationPatch(path="/value", value="updated")],
reason="unknown resolver target",
)
assert orch.config.value == "default"
@pytest.mark.asyncio
async def test_apply_patch_explicit_target_does_not_resolve_default_layer() -> None:
layer = RawWritableLayer(name="target", data={"value": "original"})
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[layer],
default_layer_resolver=lambda: (_ for _ in ()).throw(
AssertionError("resolver should not be called")
),
)
result = await orch.apply_patch(
[
ReplaceOperationPatch(
path="/value", value="updated", target_layer_name="target"
)
],
reason="explicit target only",
)
assert result == []
assert orch.config.value == "updated"
@pytest.mark.asyncio
async def test_apply_patch_end_to_end_routes_default_writes_to_project_layer(
monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path
) -> None:
workspace = tmp_working_directory / "workspace"
workspace.mkdir()
project_config_path = workspace / ".vibe" / "config.toml"
project_config_path.parent.mkdir(parents=True, exist_ok=True)
project_config_path.write_text('default_agent = "plan"\n', encoding="utf-8")
user_config_path = tmp_working_directory / "user.toml"
user_config_path.write_text('default_agent = "accept-edits"\n', encoding="utf-8")
trusted_folders_manager.add_trusted(project_config_path.parent)
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
user_layer = UserConfigLayer(path=user_config_path)
project_layer = ProjectConfigLayer(path=workspace)
environment_layer = EnvironmentLayer(schema=CliRoutingSchema)
overrides_layer = OverridesLayer(data={"enabled_tools": ["read"]})
def resolve_default_layer() -> ConfigLayer[RawConfig]:
if project_layer.is_file_discovered:
return project_layer
return user_layer
orch = await ConfigOrchestrator.create(
schema=CliRoutingSchema,
layers=[user_layer, project_layer, environment_layer, overrides_layer],
default_layer_resolver=resolve_default_layer,
)
assert project_layer.is_file_discovered is True
assert orch.config.active_model == "env-model"
assert orch.config.default_agent == "plan"
assert orch.config.enabled_tools == ["read"]
result = await orch.apply_patch(
[
AddOperationPatch(path="/active_model", value="persisted-in-project-file"),
ReplaceOperationPatch(path="/default_agent", value="auto-approve"),
],
reason="update runtime defaults",
)
failure = assert_single_failure(result, NotImplementedError)
assert str(failure) == "ProjectConfigLayer patch persistence is not implemented yet"
with project_config_path.open("rb") as file:
assert tomllib.load(file) == {"default_agent": "plan"}
with user_config_path.open("rb") as file:
assert tomllib.load(file) == {"default_agent": "accept-edits"}
assert orch.config.active_model == "env-model"
assert orch.config.default_agent == "plan"
assert orch.config.enabled_tools == ["read"]
@pytest.mark.asyncio
async def test_subscribe_registers_on_the_bus() -> None:
bus = EventBus()
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus)
orch = await ConfigOrchestrator.create(
schema=SimpleSchema,
layers=[],
default_layer_resolver=unused_default_layer,
bus=bus,
)
received: list[ConfigChangeEvent] = []
orch.subscribe(received.append)

View file

@ -50,7 +50,9 @@ api_base = "https://api.mistral.ai/v1"
from vibe.core.config.orchestrator import ConfigOrchestrator
layer = UserConfigLayer(path=toml_path)
orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer])
orchestrator = await ConfigOrchestrator.create(
schema=MinimalSchema, layers=[layer], default_layer_resolver=lambda: layer
)
assert orchestrator.config.models.active_model == "mistral-large"
assert orchestrator.config.models.models == [

View file

@ -357,6 +357,35 @@ def test_delete_attempts_legacy_service_when_current_service_raises(
assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES]
def _raise_unloadable_backend(*_args: str) -> None:
# Mirrors keyring.get_keyring() failing because PYTHON_KEYRING_BACKEND points
# at a backend module absent from Vibe's venv (e.g. flyte._keyring.file).
raise ModuleNotFoundError("No module named 'flyte'")
def test_get_returns_none_when_backend_module_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "get_password", _raise_unloadable_backend)
assert get_api_key_from_keyring("CUSTOM_API_KEY") is None
def test_set_converts_backend_import_error_to_keyring_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "set_password", _raise_unloadable_backend)
with pytest.raises(KeyringError):
set_api_key_in_keyring("CUSTOM_API_KEY", "new-key")
def test_delete_converts_backend_import_error_to_keyring_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(keyring, "delete_password", _raise_unloadable_backend)
with pytest.raises(KeyringError):
delete_api_key_from_keyring("CUSTOM_API_KEY")
def test_delete_prefers_operation_error_over_missing_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -190,6 +190,18 @@ class TestKeyringTokenStorage:
assert "api_key_env" in msg
assert exc_info.value.server_alias == "linear"
def test_unloadable_backend_raises_headless(self) -> None:
# PYTHON_KEYRING_BACKEND can point at a backend module that is absent from
# Vibe's isolated venv (e.g. flyte._keyring.file in Flyte/Slurm envs), so
# get_keyring() raises ModuleNotFoundError instead of returning a backend.
with patch(
"vibe.core.auth.mcp_oauth.keyring.get_keyring",
side_effect=ModuleNotFoundError("No module named 'flyte'"),
):
with pytest.raises(MCPOAuthHeadlessError) as exc_info:
KeyringTokenStorage(alias="notion")
assert exc_info.value.server_alias == "notion"
class TestFingerprint:
def test_compute_stable_across_scope_order(

View file

@ -0,0 +1,195 @@
from __future__ import annotations
from pathlib import Path
import tomllib
import pytest
from tests.conftest import build_test_vibe_config
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
from vibe.core.tools.mcp_settings import (
MCPServerAddError,
parse_mcp_add_transport,
persist_oauth_mcp_server,
)
def _persisted_servers(config_dir: Path) -> list[dict]:
with (config_dir / "config.toml").open("rb") as file:
return tomllib.load(file).get("mcp_servers", [])
@pytest.mark.parametrize(
("url", "expected_url"),
[
("https://mcp.example.com/mcp", "https://mcp.example.com/mcp"),
("http://localhost:8000/mcp", "http://localhost:8000/mcp"),
("http://127.0.0.1:8000/mcp", "http://127.0.0.1:8000/mcp"),
("https://example.com:443/mcp?tenant=a", "https://example.com/mcp?tenant=a"),
],
)
def test_add_oauth_mcp_server_accepts_supported_urls(
url: str, expected_url: str, config_dir: Path
) -> None:
result = persist_oauth_mcp_server(build_test_vibe_config(), url=url)
assert result.created is True
assert result.url == expected_url
assert _persisted_servers(config_dir)[0]["url"] == expected_url
@pytest.mark.parametrize(
"url",
[
"mcp.example.com/mcp",
"ftp://mcp.example.com/mcp",
"https:///mcp",
"http://mcp.example.com/mcp",
"https://mcp.example.com/mcp#section",
],
)
def test_add_oauth_mcp_server_rejects_invalid_urls(url: str) -> None:
with pytest.raises(MCPServerAddError):
persist_oauth_mcp_server(build_test_vibe_config(), url=url)
@pytest.mark.parametrize(
("url", "expected_name"),
[
("https://mcp.linear.app/mcp", "linear"),
("https://example.com/mcp", "example"),
("https://mcp.example.com/notion/mcp", "example"),
("https://mcp.localhost/notion/mcp", "notion"),
],
)
def test_add_oauth_mcp_server_generates_alias(url: str, expected_name: str) -> None:
result = persist_oauth_mcp_server(build_test_vibe_config(), url=url)
assert result.name == expected_name
def test_add_oauth_mcp_server_suffixes_generated_alias_collision(
config_dir: Path,
) -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="linear",
transport="streamable-http",
url="https://other.example.com/mcp",
)
]
)
result = persist_oauth_mcp_server(config, url="https://mcp.linear.app/mcp")
assert result.name == "linear_2"
assert _persisted_servers(config_dir)[0]["name"] == "linear_2"
def test_add_oauth_mcp_server_is_idempotent_for_existing_url(config_dir: Path) -> None:
first = persist_oauth_mcp_server(
build_test_vibe_config(), url="https://mcp.linear.app/mcp"
)
second = persist_oauth_mcp_server(
VibeConfig.load(), url="https://mcp.linear.app:443/mcp/"
)
assert first.created is True
assert second.created is False
assert second.name == "linear"
assert len(_persisted_servers(config_dir)) == 1
def test_add_oauth_mcp_server_rejects_active_static_url_match() -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="docs",
transport="streamable-http",
url="https://mcp.example.com/mcp",
)
]
)
with pytest.raises(MCPServerAddError, match="`docs` with static auth"):
persist_oauth_mcp_server(config, url="https://mcp.example.com/mcp")
def test_add_oauth_mcp_server_rejects_persisted_static_url_match() -> None:
VibeConfig.save_updates({
"mcp_servers": [
{
"name": "docs",
"transport": "streamable-http",
"url": "https://mcp.example.com/mcp",
}
]
})
with pytest.raises(MCPServerAddError, match="`docs` with static auth"):
persist_oauth_mcp_server(
build_test_vibe_config(), url="https://mcp.example.com/mcp"
)
def test_add_oauth_mcp_server_rejects_existing_url_with_different_name() -> None:
persist_oauth_mcp_server(build_test_vibe_config(), url="https://mcp.linear.app/mcp")
with pytest.raises(MCPServerAddError, match="already configured as `linear`"):
persist_oauth_mcp_server(
VibeConfig.load(), url="https://mcp.linear.app/mcp", name="other"
)
def test_add_oauth_mcp_server_rejects_explicit_alias_collision() -> None:
config = build_test_vibe_config(
mcp_servers=[
MCPStreamableHttp(
name="linear",
transport="streamable-http",
url="https://other.example.com/mcp",
)
]
)
with pytest.raises(MCPServerAddError, match="name `linear` is already configured"):
persist_oauth_mcp_server(
config, url="https://mcp.example.com/mcp", name="linear"
)
def test_add_oauth_mcp_server_persists_loadable_oauth_config() -> None:
persist_oauth_mcp_server(
build_test_vibe_config(),
url="https://mcp.example.com/mcp",
name="docs",
scopes=["read", "write"],
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "docs"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == ["read", "write"]
def test_add_oauth_mcp_server_persists_http_transport() -> None:
persist_oauth_mcp_server(
build_test_vibe_config(),
url="https://mcp.example.com/mcp",
name="docs",
transport="http",
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPHttp)
assert server.transport == "http"
assert isinstance(server.auth, MCPOAuth)
def test_parse_mcp_add_transport_rejects_unsupported_transport() -> None:
with pytest.raises(MCPServerAddError, match="http, streamable-http"):
parse_mcp_add_transport("sse")

View file

@ -188,6 +188,24 @@ async def test_find_file_result_is_cached(tmp_working_directory: Path) -> None:
assert layer._config_file_path is cached_path
@pytest.mark.asyncio
async def test_is_file_discovered_reflects_cached_discovery_state(
tmp_working_directory: Path,
) -> None:
trusted_folders_manager.add_trusted(tmp_working_directory)
config_path = tmp_working_directory / ".vibe" / "config.toml"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text('active_model = "project-model"\n')
layer = ProjectConfigLayer(path=tmp_working_directory)
assert layer.is_file_discovered is False
await layer.load()
assert layer.is_file_discovered is True
@pytest.mark.asyncio
async def test_config_file_added_after_first_search_is_not_detected(
tmp_working_directory: Path,

View file

@ -58,7 +58,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
@pytest.mark.asyncio
async def test_apply_raises_when_file_does_not_exist(
async def test_apply_creates_file_when_it_does_not_exist(
tmp_working_directory: Path,
) -> None:
path = tmp_working_directory / random_config_file_name()
@ -67,15 +67,16 @@ async def test_apply_raises_when_file_does_not_exist(
await layer.load()
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
)
assert not path.exists()
with path.open("rb") as file:
assert tomllib.load(file) == {"active_model": "mistral-large"}
assert layer.fingerprint == create_file_fingerprint(file)
@pytest.mark.asyncio
@ -224,7 +225,7 @@ async def test_apply_raises_when_cache_is_invalidated(
@pytest.mark.asyncio
async def test_apply_raises_when_parent_directory_does_not_exist(
async def test_apply_creates_parent_directory_when_it_does_not_exist(
tmp_working_directory: Path,
) -> None:
path = tmp_working_directory / "nested" / random_config_file_name()
@ -232,15 +233,15 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
await layer.load()
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
await layer.apply(
ConfigPatch(
AddOperationPatch(path="/active_model", value="mistral-large"),
fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT,
)
)
assert not path.exists()
with path.open("rb") as file:
assert tomllib.load(file) == {"active_model": "mistral-large"}
@pytest.mark.asyncio

View file

@ -56,7 +56,7 @@ provider = "mistral"
layer = UserConfigLayer(path=toml_path)
orchestrator = await ConfigOrchestrator[VibeConfig].create(
schema=VibeConfig, layers=[layer]
schema=VibeConfig, layers=[layer], default_layer_resolver=lambda: layer
)
config = orchestrator.config

4
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-23T12:49:20.1698Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P2D"
[[package]]
@ -831,7 +831,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.18.0"
version = "2.18.1"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.18.0"
__version__ = "2.18.1"

View file

@ -130,8 +130,9 @@ class CommandRegistry:
aliases=frozenset(["/mcp", "/connectors"]),
description=(
"Display available MCP servers and connectors. "
"Pass a name to list tools; subcommands: status, "
"login <alias>, logout <alias>"
"Pass a name to list tools; subcommands: add <url> "
"[--transport http|streamable-http], status, login <alias>, "
"logout <alias>"
),
handler="_show_mcp",
),

View file

@ -52,6 +52,12 @@ from vibe.cli.textual_ui.lazy_audio_managers import (
create_default_narrator_manager,
create_default_voice_manager,
)
from vibe.cli.textual_ui.mcp_commands import (
MCP_ADD_HELP,
is_mcp_add_help_request,
parse_mcp_add_args,
parse_mcp_subcommand,
)
from vibe.cli.textual_ui.message_queue import MessageQueue, QueueController, QueuePorts
from vibe.cli.textual_ui.notifications import (
NotificationContext,
@ -202,7 +208,11 @@ from vibe.core.tools.builtins.ask_user_question import (
Question,
)
from vibe.core.tools.connectors.counts import compute_connector_counts
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.mcp_settings import (
MCPServerAddError,
persist_mcp_toggle,
persist_oauth_mcp_server,
)
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import (
MAX_IMAGE_BYTES,
@ -2154,24 +2164,24 @@ class VibeApp(App): # noqa: PLR0904
return "Refreshed."
async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool:
parts = cmd_args.strip().split(None, 1)
if not parts or parts[0] not in {"login", "logout", "status"}:
parsed = parse_mcp_subcommand(cmd_args)
if parsed is None:
return False
subcommand = parts[0]
arg = parts[1].strip() if len(parts) > 1 else ""
match subcommand:
match parsed.name:
case "add":
await self._mcp_add(parsed.args)
case "status":
if arg:
if parsed.args:
await self._mount_and_scroll(
ErrorMessage("Usage: /mcp status", collapsed=True)
)
return True
await self._show_mcp_status()
case "login":
await self._mcp_login(arg)
await self._mcp_login(parsed.args)
case "logout":
await self._mcp_logout(arg)
await self._mcp_logout(parsed.args)
return True
async def _show_mcp_status(self) -> None:
@ -2253,6 +2263,49 @@ class VibeApp(App): # noqa: PLR0904
UserCommandMessage(f"MCP server `{alias}` logged out.")
)
async def _mcp_add(self, raw_args: str) -> None:
if is_mcp_add_help_request(raw_args):
await self._mount_and_scroll(UserCommandMessage(MCP_ADD_HELP))
return
try:
args = parse_mcp_add_args(raw_args)
except ValueError as exc:
await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True))
return
try:
result = persist_oauth_mcp_server(
self.agent_loop.config,
url=args.url,
name=args.name,
scopes=args.scopes,
transport=args.transport,
)
except MCPServerAddError as exc:
await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True))
return
self.agent_loop.refresh_config()
await self._refresh_mcp_browser()
head = (
f"Added OAuth MCP server `{result.name}`."
if result.created
else f"OAuth MCP server `{result.name}` is already configured."
)
tail = (
"Starting OAuth login..."
if args.login
else (
f"Run `/mcp login {result.name}` to authenticate, "
"or `/mcp status` to inspect it."
)
)
await self._mount_and_scroll(UserCommandMessage(f"{head}\n{tail}"))
if args.login:
await self._mcp_login(result.name)
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
if await self._maybe_handle_mcp_subcommand(cmd_args):
return
@ -3727,10 +3780,27 @@ class VibeApp(App): # noqa: PLR0904
async def _run_app_with_cleanup(app: VibeApp) -> str | None:
from vibe.cli.stderr_guard import stderr_guard
loop = asyncio.get_running_loop()
if not WINDOWS:
try:
def _sigterm_handler() -> None:
loop.remove_signal_handler(signal.SIGTERM)
app._force_quit()
loop.add_signal_handler(signal.SIGTERM, _sigterm_handler)
except (NotImplementedError, OSError):
pass
try:
with stderr_guard():
return await app.run_async()
finally:
if not WINDOWS:
try:
loop.remove_signal_handler(signal.SIGTERM)
except (NotImplementedError, OSError):
pass
sys.stderr.write("Closing\u2026\r")
sys.stderr.flush()
try:

View file

@ -0,0 +1,123 @@
from __future__ import annotations
from dataclasses import dataclass
import shlex
from typing import Literal
from vibe.core.tools.mcp_settings import MCPAddTransport, parse_mcp_add_transport
MCPSubcommandName = Literal["add", "login", "logout", "status"]
MCP_ADD_USAGE = (
"Usage: /mcp add <url> [--name <alias>] [--scope <scope> ...] "
"[--transport <http|streamable-http>] [--no-login]"
)
MCP_ADD_HELP = f"""{MCP_ADD_USAGE}
OAuth-only shortcut for hosted MCP servers.
Defaults to streamable-http; pass --transport http for servers documented with
HTTP transport.
For API-key/static auth, edit config.toml."""
@dataclass(frozen=True, slots=True)
class MCPSubcommand:
name: MCPSubcommandName
args: str
@dataclass(frozen=True, slots=True)
class MCPAddArgs:
url: str
name: str | None
scopes: list[str]
transport: MCPAddTransport
login: bool
def parse_mcp_subcommand(raw_args: str) -> MCPSubcommand | None:
parts = raw_args.strip().split(None, 1)
if not parts:
return None
name = _parse_mcp_subcommand_name(parts[0])
if name is None:
return None
args = parts[1].strip() if len(parts) > 1 else ""
return MCPSubcommand(name=name, args=args)
def is_mcp_add_help_request(raw_args: str) -> bool:
return raw_args.strip() in {"--help", "-h"}
def parse_mcp_add_args(raw_args: str) -> MCPAddArgs:
try:
tokens = shlex.split(raw_args)
except ValueError as exc:
raise ValueError(f"Invalid /mcp add arguments: {exc}") from exc
url: str | None = None
name: str | None = None
scopes: list[str] = []
transport: MCPAddTransport = "streamable-http"
transport_seen = False
login = True
index = 0
while index < len(tokens):
token = tokens[index]
match token:
case "--no-login":
login = False
index += 1
case "--name":
if name is not None:
raise ValueError("Usage: /mcp add accepts --name only once.")
name = _mcp_add_option_value(tokens, index, "--name", "<alias>")
index += 2
case "--transport":
if transport_seen:
raise ValueError("Usage: /mcp add accepts --transport only once.")
transport = parse_mcp_add_transport(
_mcp_add_option_value(
tokens, index, "--transport", "<http|streamable-http>"
)
)
transport_seen = True
index += 2
case "--scope":
scopes.append(
_mcp_add_option_value(tokens, index, "--scope", "<scope>")
)
index += 2
case _ if token.startswith("--"):
raise ValueError(f"Unknown /mcp add option: {token}")
case _:
if url is not None:
raise ValueError(MCP_ADD_USAGE)
url = token
index += 1
if url is None:
raise ValueError(MCP_ADD_USAGE)
return MCPAddArgs(
url=url, name=name, scopes=scopes, transport=transport, login=login
)
def _parse_mcp_subcommand_name(value: str) -> MCPSubcommandName | None:
match value:
case "add" | "login" | "logout" | "status":
return value
case _:
return None
def _mcp_add_option_value(
tokens: list[str], index: int, option: str, placeholder: str
) -> str:
if index + 1 >= len(tokens) or tokens[index + 1].startswith("--"):
raise ValueError(f"Usage: /mcp add {option} {placeholder}")
return tokens[index + 1]

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import random
from typing import Any
from textual.app import ComposeResult
@ -10,6 +11,9 @@ from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
WIDTH = 22
HEIGHT = 12
FRAME_INTERVAL_S = 0.16
CYCLE_DELAY_MIN_S = 5.0
CYCLE_DELAY_MAX_S = 20.0
STARTING_DOTS = [
set[int](),
{6, 7, 15, 19},
@ -160,6 +164,11 @@ TRANSITIONS = [
]
# cf render_braille() docstring for coordinates convention
# Transition indices reached right after a head settles into a direction, with
# the eyes open. The cat may rest at any of these, on top of the end of cycle.
EYES_OPEN_PAUSE_FRAMES = frozenset({5, 11, 21, 24})
MID_CYCLE_PAUSE_CHANCE = 0.25
class PetitChat(Static):
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
@ -171,6 +180,7 @@ class PetitChat(Static):
self._do_animate = animate
self._freeze_requested = False
self._timer: Timer | None = None
self._resume_frame: int | None = None
def compose(self) -> ComposeResult:
yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat")
@ -178,7 +188,9 @@ class PetitChat(Static):
def on_mount(self) -> None:
self._inner = self.query_one(".petit-chat", Static)
if self._do_animate:
self._timer = self.set_interval(0.16, self._apply_next_transition)
self._timer = self.set_interval(
FRAME_INTERVAL_S, self._apply_next_transition
)
def freeze_animation(self) -> None:
self._freeze_requested = True
@ -195,3 +207,35 @@ class PetitChat(Static):
self._dots |= transition["add"]
self._transition_index = (self._transition_index + 1) % len(TRANSITIONS)
self._inner.update(render_braille(self._dots, WIDTH, HEIGHT))
if not self._may_stop():
return
if self._transition_index == 0 or (
self._transition_index in EYES_OPEN_PAUSE_FRAMES
and random.random() < MID_CYCLE_PAUSE_CHANCE
):
self._pause_between_cycles()
def _may_stop(self) -> bool:
# After a stop, play one full cycle back to the frame we stopped at
# before considering any new stop.
if self._resume_frame is None:
return True
if self._transition_index == self._resume_frame:
self._resume_frame = None
return True
return False
def _pause_between_cycles(self) -> None:
if self._timer:
self._timer.stop()
self._resume_frame = self._transition_index
delay = random.uniform(CYCLE_DELAY_MIN_S, CYCLE_DELAY_MAX_S)
self._timer = self.set_timer(delay, self._resume_animation)
def _resume_animation(self) -> None:
if self._freeze_requested:
self._timer = None
return
self._timer = self.set_interval(FRAME_INTERVAL_S, self._apply_next_transition)

View file

@ -48,7 +48,7 @@ def classify(
) -> ClassifiedInput:
if value.startswith("&") and commands.has_command("teleport"):
return Teleport(target=value[1:])
if value.startswith("/") and commands.parse_command(value) is not None:
if commands.parse_command(value) is not None:
return SlashCommand()
if value.startswith("/"):
if (expanded := expand_skill(value)) is not None:

View file

@ -167,7 +167,10 @@ class KeyringTokenStorage(TokenStorage):
*,
fallback_client_info: OAuthClientInformationFull | None = None,
) -> None:
backend = keyring.get_keyring()
try:
backend = keyring.get_keyring()
except (ImportError, keyring.errors.KeyringError) as exc:
raise MCPOAuthHeadlessError(server_alias=alias) from exc
if isinstance(backend, keyring.backends.fail.Keyring):
raise MCPOAuthHeadlessError(server_alias=alias)
self._alias = alias

View file

@ -11,7 +11,6 @@ from pydantic import BaseModel, ConfigDict, ValidationError
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import (
MISSING_BACKING_STORE_DATA_FINGERPRINT,
ConcurrencyConflictError,
ConflictStrategy,
LayerConfigSnapshot,
@ -300,11 +299,7 @@ class ConfigLayer[S: BaseModel](ABC):
self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy
) -> _LayerState[S]:
if (
state.data is None
or state.fingerprint is None
or state.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
):
if state.data is None or state.fingerprint is None:
raise LayerNotLoadedError(self.name)
match on_conflict:

View file

@ -28,6 +28,10 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]):
def config_file_path(self) -> Path | None:
return self._config_file_path
@property
def is_file_discovered(self) -> bool:
return self._is_set and self._config_file_path is not None
async def _check_trust(self) -> bool:
await self._find_config_file()

View file

@ -4,7 +4,6 @@ import os
from pathlib import Path
import tempfile
import tomllib
from typing import ClassVar
import tomli_w
@ -21,10 +20,8 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
Pass an explicit ``path`` for testing.
"""
LAYER_NAME: ClassVar[str] = "user-toml"
def __init__(self, *, path: Path | None = None) -> None:
super().__init__(name=self.LAYER_NAME)
def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None:
super().__init__(name=name)
self._path = path or (VIBE_HOME.path / "config.toml")
async def _check_trust(self) -> bool:
@ -40,8 +37,7 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
async def _save_to_store(self, next_config: RawConfig) -> str:
if not self._path.exists():
raise FileNotFoundError(self._path)
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp_path: Path | None = None
try:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from typing import Any
from jsonpatch import JsonPatchException, apply_patch
from jsonpointer import JsonPointerException
@ -11,13 +12,10 @@ from pydantic import ValidationError
from vibe.core.config.builder import ConfigBuilder
from vibe.core.config.event_bus import EventBus
from vibe.core.config.layer import ConfigLayer, LayerNotLoadedError, RawConfig
from vibe.core.config.layers.user import UserConfigLayer
from vibe.core.config.patch import ConfigPatch, PatchOp
from vibe.core.config.patch import AddOperationPatch, ConfigPatch, PatchOp
from vibe.core.config.schema import ConfigSchema
from vibe.core.config.types import ConfigChangeCallback, ConflictStrategy
DEFAULT_PATCH_LAYER_NAME = UserConfigLayer.LAYER_NAME
class ConfigPatchValidationError(Exception):
"""Raised when the merged-config preflight rejects a patch."""
@ -29,14 +27,26 @@ class ConfigPatchValidationError(Exception):
)
class DefaultLayerResolutionError(Exception):
"""Raised when a patch needs implicit routing but no valid default is available."""
type DefaultLayerResolver = Callable[[], ConfigLayer[RawConfig]]
class ConfigOrchestrator[S: ConfigSchema]:
"""Single entry point for config management."""
def __init__(
self, builder: ConfigBuilder[S], config: S, bus: EventBus | None = None
self,
builder: ConfigBuilder[S],
config: S,
default_layer_resolver: DefaultLayerResolver,
bus: EventBus | None = None,
) -> None:
self._builder = builder
self._config = config
self._default_layer_resolver = default_layer_resolver
self._bus = bus if bus is not None else EventBus()
@classmethod
@ -45,13 +55,14 @@ class ConfigOrchestrator[S: ConfigSchema]:
*,
schema: type[S],
layers: list[ConfigLayer[RawConfig]],
default_layer_resolver: DefaultLayerResolver,
bus: EventBus | None = None,
) -> ConfigOrchestrator[S]:
"""Build an orchestrator from a schema and an ordered list of layers."""
builder = ConfigBuilder[S](schema)
builder.add_layers(layers)
config = await builder.build()
instance = cls(builder, config, bus)
instance = cls(builder, config, default_layer_resolver, bus)
return instance
@property
@ -68,6 +79,13 @@ class ConfigOrchestrator[S: ConfigSchema]:
"""Force-reload all layers and atomically replace the config snapshot."""
self._config = await self._builder.build(force_load=True)
async def set_field(
self, path: str, value: Any, reason: str = "No reason"
) -> list[BaseException]:
return await self.apply_patch(
[AddOperationPatch(path=path, value=value)], reason=reason
)
async def apply_patch(
self,
operations: list[PatchOp],
@ -97,12 +115,14 @@ class ConfigOrchestrator[S: ConfigSchema]:
raise ConfigPatchValidationError() from exc
operations_by_layer: dict[str, list[PatchOp]] = defaultdict(list)
default_layer_name: str | None = None
for op in operations:
layer_name = (
op.target_layer_name
if op.target_layer_name is not None
else DEFAULT_PATCH_LAYER_NAME
)
layer_name = op.target_layer_name
if layer_name is None:
if default_layer_name is None:
default_layer_name = self._resolve_default_layer_name()
layer_name = default_layer_name
operations_by_layer[layer_name].append(op)
tasks = []
@ -142,6 +162,15 @@ class ConfigOrchestrator[S: ConfigSchema]:
on_conflict=on_conflict,
)
def _resolve_default_layer_name(self) -> str:
layer = self._default_layer_resolver()
if layer not in self._builder.layers:
raise DefaultLayerResolutionError(
f"Default layer resolver returned unknown layer {layer.name!r}"
)
return layer.name
def subscribe(
self, callback: ConfigChangeCallback, *, keys: set[str] | None = None
) -> Callable[[], None]:

View file

@ -260,6 +260,20 @@ default_agent = "plan"
### MCP Servers
Hosted OAuth MCP servers can be added from inside Vibe:
```text
/mcp add https://mcp.linear.app/mcp
/mcp add https://mcp.example.com/mcp --name docs --scope read --transport http --no-login
```
`/mcp add` is OAuth-only. It writes `auth.type = "oauth"` with optional
scopes and starts login by default. It uses `transport = "streamable-http"`
unless you pass `--transport http`. Pass `--no-login` to add the server without
starting OAuth login. The shortcut supports `streamable-http` and `http`
transports. For API-key/static auth, edit `config.toml` using the static auth
example below.
```toml
[[mcp_servers]]
name = "my-server"
@ -581,6 +595,10 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/voice` - Configure voice settings
- `/mcp` - Display MCP servers and connector status; pass a server or connector
name to list its tools or open its auth panel when authentication is required
- `/mcp add <url>` - Add a hosted OAuth MCP server. Supports `--name <alias>`,
repeatable `--scope <scope>`, `--transport <http|streamable-http>`, and
`--no-login`. Starts OAuth login by default. OAuth-only; use `config.toml`
for API-key/static auth.
- `/mcp status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`)
- `/mcp login <alias>` - Start OAuth login for an MCP server
- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth

View file

@ -6,9 +6,34 @@ tied to a particular UI layer.
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from ipaddress import ip_address
import re
from typing import Any, Literal, cast
from urllib.parse import SplitResult, urlsplit, urlunsplit
from vibe.core.config import VibeConfig
from pydantic import ValidationError
from vibe.core.config import MCPHttp, MCPOAuth, MCPServer, MCPStreamableHttp, VibeConfig
MCPAddTransport = Literal["http", "streamable-http"]
class MCPServerAddError(ValueError):
pass
@dataclass(frozen=True)
class MCPServerAddResult:
name: str
url: str
created: bool
_DEFAULT_PORTS = {"http": 80, "https": 443}
_HOST_PREFIXES_TO_DROP = {"mcp", "www"}
_GENERIC_ALIAS_SEGMENTS = {"api", "mcp", "server"}
_LEADING_HOST_PREFIX_LABEL_MIN_COUNT = 3
def updated_tool_list(tools: list[str], name: str, disabled: bool) -> list[str]:
@ -73,3 +98,248 @@ def _persist_connector_toggle(
entry["disabled"] = disabled
connectors.append(entry)
VibeConfig.save_updates({"connectors": connectors})
def persist_oauth_mcp_server(
config: VibeConfig,
*,
url: str,
name: str | None = None,
scopes: list[str] | None = None,
transport: MCPAddTransport = "streamable-http",
) -> MCPServerAddResult:
normalized_url = _normalize_mcp_oauth_url(url)
normalized_url_key = _url_key(normalized_url)
requested_name = _normalize_server_name(name) if name is not None else None
if name is not None and not requested_name:
raise MCPServerAddError("MCP server name must contain letters or numbers.")
active_servers = list(config.mcp_servers)
if result := _find_active_url_match(
active_servers, normalized_url_key, requested_name
):
return result
raw_servers = _load_persisted_mcp_servers()
if result := _find_persisted_url_match(
raw_servers, normalized_url_key, requested_name
):
return result
raw_names = _persisted_server_names(raw_servers)
active_names = {server.name for server in active_servers} | raw_names
server_name = _resolve_new_server_name(requested_name, normalized_url, active_names)
entry: dict[str, Any] = {
"name": server_name,
"transport": transport,
"url": normalized_url,
"auth": {"type": "oauth", "scopes": scopes or []},
}
try:
model = MCPHttp if transport == "http" else MCPStreamableHttp
model.model_validate(entry)
except ValidationError as exc:
raise MCPServerAddError(f"Invalid MCP server configuration: {exc}") from exc
VibeConfig.save_updates({"mcp_servers": [*raw_servers, entry]})
return MCPServerAddResult(name=server_name, url=normalized_url, created=True)
def parse_mcp_add_transport(value: str) -> MCPAddTransport:
match value:
case "http" | "streamable-http":
return value
case _:
raise MCPServerAddError(
"MCP server transport must be one of: http, streamable-http."
)
def _find_active_url_match(
servers: list[MCPServer], normalized_url_key: str, requested_name: str | None
) -> MCPServerAddResult | None:
for server in servers:
if server.transport not in {"http", "streamable-http"}:
continue
http_server = cast("MCPHttp | MCPStreamableHttp", server)
if _url_key(http_server.url) != normalized_url_key:
continue
if not isinstance(http_server.auth, MCPOAuth):
raise MCPServerAddError(
f"MCP server URL is already configured as `{http_server.name}` "
"with static auth. `/mcp add` only supports OAuth MCP servers."
)
if requested_name is not None and requested_name != http_server.name:
raise MCPServerAddError(
f"MCP server URL is already configured as `{http_server.name}`."
)
return MCPServerAddResult(
name=http_server.name, url=http_server.url, created=False
)
return None
def _load_persisted_mcp_servers() -> list[dict[str, Any]]:
raw_servers = VibeConfig.get_persisted_config().get("mcp_servers", [])
if not isinstance(raw_servers, list):
raise MCPServerAddError("Cannot add MCP server: mcp_servers is not a list.")
return [server for server in raw_servers if isinstance(server, dict)]
def _find_persisted_url_match(
raw_servers: list[dict[str, Any]],
normalized_url_key: str,
requested_name: str | None,
) -> MCPServerAddResult | None:
for raw_server in raw_servers:
if raw_server.get("transport") not in {"http", "streamable-http"}:
continue
raw_url = raw_server.get("url")
if not isinstance(raw_url, str) or _url_key(raw_url) != normalized_url_key:
continue
raw_name = raw_server.get("name")
if not isinstance(raw_name, str):
raw_name = _suggest_server_name(raw_url)
if not _is_persisted_oauth_server(raw_server):
raise MCPServerAddError(
f"MCP server URL is already configured as `{raw_name}` "
"with static auth. `/mcp add` only supports OAuth MCP servers."
)
if requested_name is not None and requested_name != raw_name:
raise MCPServerAddError(
f"MCP server URL is already configured as `{raw_name}`."
)
return MCPServerAddResult(name=raw_name, url=raw_url, created=False)
return None
def _is_persisted_oauth_server(raw_server: dict[str, Any]) -> bool:
auth = raw_server.get("auth")
return isinstance(auth, dict) and auth.get("type") == "oauth"
def _persisted_server_names(raw_servers: list[dict[str, Any]]) -> set[str]:
names: set[str] = set()
for raw_server in raw_servers:
name = raw_server.get("name")
if isinstance(name, str):
names.add(name)
return names
def _resolve_new_server_name(
requested_name: str | None, normalized_url: str, active_names: set[str]
) -> str:
if requested_name is None:
return _dedupe_server_name(_suggest_server_name(normalized_url), active_names)
if requested_name in active_names:
raise MCPServerAddError(
f"MCP server name `{requested_name}` is already configured."
)
return requested_name
def _normalize_mcp_oauth_url(value: str) -> str:
raw_url = value.strip()
if not raw_url:
raise MCPServerAddError("MCP server URL is required.")
parsed = urlsplit(raw_url)
scheme = parsed.scheme.lower()
host = parsed.hostname
if not scheme:
raise MCPServerAddError("MCP server URL must include a scheme.")
if scheme not in {"http", "https"}:
raise MCPServerAddError("MCP server URL must use https.")
if not host:
raise MCPServerAddError("MCP server URL must include a host.")
if parsed.fragment:
raise MCPServerAddError("MCP server URL must not include a fragment.")
if scheme == "http" and not _is_loopback_host(host):
raise MCPServerAddError(
"MCP server URL must use https unless it points to localhost."
)
return _url_with_normalized_host(parsed, trim_trailing_slash=False)
def _url_key(value: str) -> str:
parsed = urlsplit(value.strip())
return _url_with_normalized_host(parsed, trim_trailing_slash=True)
def _url_with_normalized_host(parsed: SplitResult, *, trim_trailing_slash: bool) -> str:
scheme = parsed.scheme.lower()
host = parsed.hostname
if host is None:
return parsed.geturl()
hostname = host.lower()
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]"
netloc = hostname
if parsed.port is not None and parsed.port != _DEFAULT_PORTS.get(scheme):
netloc = f"{netloc}:{parsed.port}"
path = parsed.path
if trim_trailing_slash:
path = path.rstrip("/")
return urlunsplit((scheme, netloc, path, parsed.query, ""))
def _is_loopback_host(host: str) -> bool:
if host.lower() == "localhost":
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _suggest_server_name(url: str) -> str:
parsed = urlsplit(url)
host = parsed.hostname or "mcp"
labels = [label for label in host.lower().split(".") if label]
if (
len(labels) >= _LEADING_HOST_PREFIX_LABEL_MIN_COUNT
and labels[0] in _HOST_PREFIXES_TO_DROP
):
labels = labels[1:]
candidate = labels[0] if labels else ""
if candidate in _GENERIC_ALIAS_SEGMENTS:
candidate = _path_alias_candidate(parsed.path)
return _normalize_server_name(candidate) or "mcp"
def _path_alias_candidate(path: str) -> str:
for segment in path.split("/"):
normalized = _normalize_server_name(segment.lower())
if normalized and normalized not in _GENERIC_ALIAS_SEGMENTS:
return normalized
return ""
def _normalize_server_name(value: str | None) -> str:
if not value:
return ""
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", value)
normalized = normalized.strip("_-")
return normalized[:256]
def _dedupe_server_name(base: str, existing_names: set[str]) -> str:
if base not in existing_names:
return base
index = 2
while True:
candidate = f"{base}_{index}"
if candidate not in existing_names:
return candidate
index += 1

View file

@ -60,7 +60,10 @@ def _delete_macos_password(service: str, username: str) -> None:
def _set_password(service: str, username: str, password: str) -> None:
if not _should_use_macos_security():
keyring.set_password(service, username, password)
try:
keyring.set_password(service, username, password)
except ImportError as exc:
raise KeyringError("Can't load keyring backend") from exc
return
try:
@ -100,7 +103,10 @@ def _get_password(service: str, username: str) -> str | None:
raise KeyringError("Can't get password from macOS Keychain") from exc
return result.stdout.removesuffix("\n")
return keyring.get_password(service, username)
try:
return keyring.get_password(service, username)
except ImportError as exc:
raise KeyringError("Can't load keyring backend") from exc
def _delete_password(service: str, username: str) -> None:
@ -108,7 +114,10 @@ def _delete_password(service: str, username: str) -> None:
_delete_macos_password(service, username)
return
keyring.delete_password(service, username)
try:
keyring.delete_password(service, username)
except ImportError as exc:
raise KeyringError("Can't load keyring backend") from exc
def _migrate_legacy_password(username: str, password: str, legacy_service: str) -> None:

View file

@ -1,9 +1,7 @@
# What's new in v2.18.1
- **OAuth MCP server setup**: Added `/mcp add` command to add and authenticate OAuth MCP servers directly from the TUI
# What's new in v2.18.0
- **Clipboard image paste**: Paste images directly from clipboard in the TUI on macOS
# What's new in v2.17.0
- **MCP OAuth login**: Authenticate OAuth-backed MCP servers from the TUI with the new `/mcp login`, `/mcp logout`, and `/mcp status` commands
- **`--check-upgrade`**: New flag to check for a Vibe update on demand, prompt to install it, and exit
- **`--yolo`**: New shorthand alias for `--auto-approve`