v2.18.1 (#856)
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:
parent
e607ccbb00
commit
ed5b7192e6
33 changed files with 1456 additions and 125 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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/),
|
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).
|
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
|
## [2.18.0] - 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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:
|
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
|
```toml
|
||||||
# Example MCP server configurations
|
# Example MCP server configurations
|
||||||
[[mcp_servers]]
|
[[mcp_servers]]
|
||||||
|
|
@ -586,6 +600,20 @@ Key fields:
|
||||||
- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
|
- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
|
||||||
- `env`: Environment variables to set for the MCP server of transport type stdio
|
- `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:
|
MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.18.0"
|
version = "2.18.1"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[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"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.18.0"
|
version = "2.18.1"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -215,7 +215,6 @@ select = [
|
||||||
"PLR",
|
"PLR",
|
||||||
"B0",
|
"B0",
|
||||||
"B905",
|
"B905",
|
||||||
"DOC102",
|
|
||||||
"RUF022",
|
"RUF022",
|
||||||
"RUF010",
|
"RUF010",
|
||||||
"RUF012",
|
"RUF012",
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
@ -172,7 +172,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
26
tests/cli/test_input_kinds.py
Normal file
26
tests/cli/test_input_kinds.py
Normal 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
|
||||||
212
tests/cli/test_mcp_add_command.py
Normal file
212
tests/cli/test_mcp_add_command.py
Normal 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
|
||||||
|
)
|
||||||
38
tests/cli/test_ui_exit_command.py
Normal file
38
tests/cli/test_ui_exit_command.py
Normal 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 == []
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
import signal
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from textual.app import WINDOWS
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_app
|
from tests.conftest import build_test_vibe_app
|
||||||
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
|
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)
|
await _run_app_with_cleanup(app)
|
||||||
|
|
||||||
cleanup.assert_awaited_once()
|
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)
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,14 @@ from vibe.core.config.layer import (
|
||||||
RawConfig,
|
RawConfig,
|
||||||
)
|
)
|
||||||
from vibe.core.config.layers.environment import EnvironmentLayer
|
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.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 (
|
from vibe.core.config.patch import (
|
||||||
AddOperationPatch,
|
AddOperationPatch,
|
||||||
RemoveOperationPatch,
|
RemoveOperationPatch,
|
||||||
|
|
@ -36,6 +42,7 @@ from vibe.core.config.types import (
|
||||||
ConfigChangeEvent,
|
ConfigChangeEvent,
|
||||||
LayerConfigSnapshot,
|
LayerConfigSnapshot,
|
||||||
)
|
)
|
||||||
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
|
|
||||||
|
|
||||||
class FakeLayer(ConfigLayer[RawConfig]):
|
class FakeLayer(ConfigLayer[RawConfig]):
|
||||||
|
|
@ -143,6 +150,12 @@ class RoutingSchema(ConfigSchema):
|
||||||
default_agent: Annotated[str, WithReplaceMerge()] = "default-agent"
|
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):
|
class RequiredPairSchema(ConfigSchema):
|
||||||
first: Annotated[str, WithReplaceMerge()]
|
first: Annotated[str, WithReplaceMerge()]
|
||||||
second: Annotated[str, WithReplaceMerge()]
|
second: Annotated[str, WithReplaceMerge()]
|
||||||
|
|
@ -157,23 +170,33 @@ def assert_single_failure[E: BaseException](
|
||||||
return failure
|
return failure
|
||||||
|
|
||||||
|
|
||||||
|
def unused_default_layer() -> ConfigLayer[RawConfig]:
|
||||||
|
return FakeLayer(name="unused-default", data={})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_builds_config() -> None:
|
async def test_create_builds_config() -> None:
|
||||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
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"}
|
assert orch.config.model_dump() == {"value": "hello"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_layer_returns_named_layer() -> None:
|
async def test_get_layer_returns_named_layer() -> None:
|
||||||
layer = FakeLayer(name="my-layer", data={})
|
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
|
assert orch.get_layer("my-layer") is layer
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_layer_unknown_raises() -> None:
|
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'"):
|
with pytest.raises(KeyError, match="No layer named 'unknown'"):
|
||||||
orch.get_layer("unknown")
|
orch.get_layer("unknown")
|
||||||
|
|
||||||
|
|
@ -181,7 +204,9 @@ async def test_get_layer_unknown_raises() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_picks_up_changes() -> None:
|
async def test_reload_picks_up_changes() -> None:
|
||||||
layer = FakeLayer(name="mutable", data={"value": "original"})
|
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"
|
assert orch.config.value == "original"
|
||||||
|
|
||||||
layer._data = {"value": "updated"}
|
layer._data = {"value": "updated"}
|
||||||
|
|
@ -192,21 +217,27 @@ async def test_reload_picks_up_changes() -> None:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_config_is_immutable() -> None:
|
async def test_config_is_immutable() -> None:
|
||||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
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"):
|
with pytest.raises(ValidationError, match="frozen"):
|
||||||
orch.config.value = "changed" # type: ignore[misc]
|
orch.config.value = "changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_origin_of_missing_key_returns_none() -> None:
|
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
|
assert orch.config.origin_of("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_patch_empty_operations_is_noop() -> None:
|
async def test_apply_patch_empty_operations_is_noop() -> None:
|
||||||
layer = FakeLayer(name="test", data={"value": "hello"})
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None:
|
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:
|
with pytest.raises(ConfigPatchValidationError) as exc_info:
|
||||||
await orch.apply_patch(
|
await orch.apply_patch(
|
||||||
[ReplaceOperationPatch(path="/value", value={"invalid": "shape"})],
|
[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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
|
async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
|
||||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
layer = NormalizingWritableLayer(name="user-toml", data={})
|
||||||
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(
|
result = await orch.apply_patch(
|
||||||
[
|
[
|
||||||
|
|
@ -265,11 +285,11 @@ async def test_apply_patch_unknown_explicit_target_returns_failure() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> (
|
async def test_apply_patch_uses_default_layer_resolver_and_reloads_config() -> None:
|
||||||
None
|
layer = NormalizingWritableLayer(name="user-toml", data={})
|
||||||
):
|
orch = await ConfigOrchestrator.create(
|
||||||
layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={})
|
schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer
|
||||||
orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer])
|
)
|
||||||
|
|
||||||
result = await orch.apply_patch(
|
result = await orch.apply_patch(
|
||||||
[AddOperationPatch(path="/value", value="updated")], reason="test update"
|
[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"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
|
async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None:
|
||||||
first_layer = FieldWritableLayer(
|
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"})
|
second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"})
|
||||||
orch = await ConfigOrchestrator.create(
|
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(
|
result = await orch.apply_patch(
|
||||||
|
|
@ -325,7 +360,9 @@ async def test_apply_patch_returns_layer_apply_error_in_failures(
|
||||||
error: Exception,
|
error: Exception,
|
||||||
) -> None:
|
) -> None:
|
||||||
layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error)
|
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(
|
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"})
|
loaded_layer = FakeLayer(name="loaded", data={"value": "hello"})
|
||||||
target_layer = FakeLayer(name="target", data={"value": "hello"})
|
target_layer = FakeLayer(name="target", data={"value": "hello"})
|
||||||
orch = await ConfigOrchestrator.create(
|
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()
|
await target_layer.invalidate_cache()
|
||||||
|
|
||||||
|
|
@ -374,7 +413,9 @@ async def test_apply_patch_applies_layers_in_parallel() -> None:
|
||||||
barrier=barrier,
|
barrier=barrier,
|
||||||
)
|
)
|
||||||
orch = await ConfigOrchestrator.create(
|
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(
|
result = await orch.apply_patch(
|
||||||
|
|
@ -412,7 +453,11 @@ deprecated_setting = true
|
||||||
)
|
)
|
||||||
|
|
||||||
user_layer = UserConfigLayer(path=toml_path)
|
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(
|
result = await orch.apply_patch(
|
||||||
[
|
[
|
||||||
|
|
@ -437,22 +482,26 @@ deprecated_setting = true
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
tmp_working_directory: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
toml_path = tmp_working_directory / "config.toml"
|
toml_path = tmp_working_directory / "config.toml"
|
||||||
user_layer = UserConfigLayer(path=toml_path)
|
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(
|
result = await orch.apply_patch(
|
||||||
[AddOperationPatch(path="/value", value="created-later")],
|
[AddOperationPatch(path="/value", value="created-later")],
|
||||||
reason="fallback write without user file",
|
reason="fallback write without user file",
|
||||||
)
|
)
|
||||||
|
|
||||||
failure = assert_single_failure(result, LayerNotLoadedError)
|
assert result == []
|
||||||
assert str(failure) == "Layer 'user-toml' must be loaded before applying patches"
|
with toml_path.open("rb") as file:
|
||||||
assert not toml_path.exists()
|
assert tomllib.load(file) == {"value": "created-later"}
|
||||||
assert orch.config.value == "default"
|
assert orch.config.value == "created-later"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)
|
user_layer = UserConfigLayer(path=toml_path)
|
||||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||||
orch = await ConfigOrchestrator.create(
|
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(
|
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)
|
user_layer = UserConfigLayer(path=toml_path)
|
||||||
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
environment_layer = EnvironmentLayer(schema=RoutingSchema)
|
||||||
orch = await ConfigOrchestrator.create(
|
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(
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_subscribe_registers_on_the_bus() -> None:
|
async def test_subscribe_registers_on_the_bus() -> None:
|
||||||
bus = EventBus()
|
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] = []
|
received: list[ConfigChangeEvent] = []
|
||||||
orch.subscribe(received.append)
|
orch.subscribe(received.append)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ api_base = "https://api.mistral.ai/v1"
|
||||||
from vibe.core.config.orchestrator import ConfigOrchestrator
|
from vibe.core.config.orchestrator import ConfigOrchestrator
|
||||||
|
|
||||||
layer = UserConfigLayer(path=toml_path)
|
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.active_model == "mistral-large"
|
||||||
assert orchestrator.config.models.models == [
|
assert orchestrator.config.models.models == [
|
||||||
|
|
|
||||||
|
|
@ -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]
|
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(
|
def test_delete_prefers_operation_error_over_missing_entry(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,18 @@ class TestKeyringTokenStorage:
|
||||||
assert "api_key_env" in msg
|
assert "api_key_env" in msg
|
||||||
assert exc_info.value.server_alias == "linear"
|
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:
|
class TestFingerprint:
|
||||||
def test_compute_stable_across_scope_order(
|
def test_compute_stable_across_scope_order(
|
||||||
|
|
|
||||||
195
tests/core/test_mcp_settings.py
Normal file
195
tests/core/test_mcp_settings.py
Normal 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")
|
||||||
|
|
@ -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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_config_file_added_after_first_search_is_not_detected(
|
async def test_config_file_added_after_first_search_is_not_detected(
|
||||||
tmp_working_directory: Path,
|
tmp_working_directory: Path,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
tmp_working_directory: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
path = tmp_working_directory / random_config_file_name()
|
path = tmp_working_directory / random_config_file_name()
|
||||||
|
|
@ -67,7 +67,6 @@ async def test_apply_raises_when_file_does_not_exist(
|
||||||
await layer.load()
|
await layer.load()
|
||||||
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
||||||
|
|
||||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
|
||||||
await layer.apply(
|
await layer.apply(
|
||||||
ConfigPatch(
|
ConfigPatch(
|
||||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||||
|
|
@ -75,7 +74,9 @@ async def test_apply_raises_when_file_does_not_exist(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -224,7 +225,7 @@ async def test_apply_raises_when_cache_is_invalidated(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
tmp_working_directory: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
path = tmp_working_directory / "nested" / random_config_file_name()
|
path = tmp_working_directory / "nested" / random_config_file_name()
|
||||||
|
|
@ -232,7 +233,6 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
||||||
|
|
||||||
await layer.load()
|
await layer.load()
|
||||||
|
|
||||||
with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"):
|
|
||||||
await layer.apply(
|
await layer.apply(
|
||||||
ConfigPatch(
|
ConfigPatch(
|
||||||
AddOperationPatch(path="/active_model", value="mistral-large"),
|
AddOperationPatch(path="/active_model", value="mistral-large"),
|
||||||
|
|
@ -240,7 +240,8 @@ async def test_apply_raises_when_parent_directory_does_not_exist(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not path.exists()
|
with path.open("rb") as file:
|
||||||
|
assert tomllib.load(file) == {"active_model": "mistral-large"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ provider = "mistral"
|
||||||
|
|
||||||
layer = UserConfigLayer(path=toml_path)
|
layer = UserConfigLayer(path=toml_path)
|
||||||
orchestrator = await ConfigOrchestrator[VibeConfig].create(
|
orchestrator = await ConfigOrchestrator[VibeConfig].create(
|
||||||
schema=VibeConfig, layers=[layer]
|
schema=VibeConfig, layers=[layer], default_layer_resolver=lambda: layer
|
||||||
)
|
)
|
||||||
config = orchestrator.config
|
config = orchestrator.config
|
||||||
|
|
||||||
|
|
|
||||||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[options]
|
[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"
|
exclude-newer-span = "P2D"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -831,7 +831,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.18.0"
|
version = "2.18.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.18.0"
|
__version__ = "2.18.1"
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,9 @@ class CommandRegistry:
|
||||||
aliases=frozenset(["/mcp", "/connectors"]),
|
aliases=frozenset(["/mcp", "/connectors"]),
|
||||||
description=(
|
description=(
|
||||||
"Display available MCP servers and connectors. "
|
"Display available MCP servers and connectors. "
|
||||||
"Pass a name to list tools; subcommands: status, "
|
"Pass a name to list tools; subcommands: add <url> "
|
||||||
"login <alias>, logout <alias>"
|
"[--transport http|streamable-http], status, login <alias>, "
|
||||||
|
"logout <alias>"
|
||||||
),
|
),
|
||||||
handler="_show_mcp",
|
handler="_show_mcp",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ from vibe.cli.textual_ui.lazy_audio_managers import (
|
||||||
create_default_narrator_manager,
|
create_default_narrator_manager,
|
||||||
create_default_voice_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.message_queue import MessageQueue, QueueController, QueuePorts
|
||||||
from vibe.cli.textual_ui.notifications import (
|
from vibe.cli.textual_ui.notifications import (
|
||||||
NotificationContext,
|
NotificationContext,
|
||||||
|
|
@ -202,7 +208,11 @@ from vibe.core.tools.builtins.ask_user_question import (
|
||||||
Question,
|
Question,
|
||||||
)
|
)
|
||||||
from vibe.core.tools.connectors.counts import compute_connector_counts
|
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.tools.permissions import RequiredPermission
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
MAX_IMAGE_BYTES,
|
MAX_IMAGE_BYTES,
|
||||||
|
|
@ -2154,24 +2164,24 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
return "Refreshed."
|
return "Refreshed."
|
||||||
|
|
||||||
async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool:
|
async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool:
|
||||||
parts = cmd_args.strip().split(None, 1)
|
parsed = parse_mcp_subcommand(cmd_args)
|
||||||
if not parts or parts[0] not in {"login", "logout", "status"}:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
subcommand = parts[0]
|
match parsed.name:
|
||||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
case "add":
|
||||||
match subcommand:
|
await self._mcp_add(parsed.args)
|
||||||
case "status":
|
case "status":
|
||||||
if arg:
|
if parsed.args:
|
||||||
await self._mount_and_scroll(
|
await self._mount_and_scroll(
|
||||||
ErrorMessage("Usage: /mcp status", collapsed=True)
|
ErrorMessage("Usage: /mcp status", collapsed=True)
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
await self._show_mcp_status()
|
await self._show_mcp_status()
|
||||||
case "login":
|
case "login":
|
||||||
await self._mcp_login(arg)
|
await self._mcp_login(parsed.args)
|
||||||
case "logout":
|
case "logout":
|
||||||
await self._mcp_logout(arg)
|
await self._mcp_logout(parsed.args)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _show_mcp_status(self) -> None:
|
async def _show_mcp_status(self) -> None:
|
||||||
|
|
@ -2253,6 +2263,49 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
UserCommandMessage(f"MCP server `{alias}` logged out.")
|
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:
|
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
|
||||||
if await self._maybe_handle_mcp_subcommand(cmd_args):
|
if await self._maybe_handle_mcp_subcommand(cmd_args):
|
||||||
return
|
return
|
||||||
|
|
@ -3727,10 +3780,27 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
async def _run_app_with_cleanup(app: VibeApp) -> str | None:
|
async def _run_app_with_cleanup(app: VibeApp) -> str | None:
|
||||||
from vibe.cli.stderr_guard import stderr_guard
|
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:
|
try:
|
||||||
with stderr_guard():
|
with stderr_guard():
|
||||||
return await app.run_async()
|
return await app.run_async()
|
||||||
finally:
|
finally:
|
||||||
|
if not WINDOWS:
|
||||||
|
try:
|
||||||
|
loop.remove_signal_handler(signal.SIGTERM)
|
||||||
|
except (NotImplementedError, OSError):
|
||||||
|
pass
|
||||||
sys.stderr.write("Closing\u2026\r")
|
sys.stderr.write("Closing\u2026\r")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
123
vibe/cli/textual_ui/mcp_commands.py
Normal file
123
vibe/cli/textual_ui/mcp_commands.py
Normal 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]
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
|
@ -10,6 +11,9 @@ from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
|
||||||
|
|
||||||
WIDTH = 22
|
WIDTH = 22
|
||||||
HEIGHT = 12
|
HEIGHT = 12
|
||||||
|
FRAME_INTERVAL_S = 0.16
|
||||||
|
CYCLE_DELAY_MIN_S = 5.0
|
||||||
|
CYCLE_DELAY_MAX_S = 20.0
|
||||||
STARTING_DOTS = [
|
STARTING_DOTS = [
|
||||||
set[int](),
|
set[int](),
|
||||||
{6, 7, 15, 19},
|
{6, 7, 15, 19},
|
||||||
|
|
@ -160,6 +164,11 @@ TRANSITIONS = [
|
||||||
]
|
]
|
||||||
# cf render_braille() docstring for coordinates convention
|
# 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):
|
class PetitChat(Static):
|
||||||
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
|
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
|
||||||
|
|
@ -171,6 +180,7 @@ class PetitChat(Static):
|
||||||
self._do_animate = animate
|
self._do_animate = animate
|
||||||
self._freeze_requested = False
|
self._freeze_requested = False
|
||||||
self._timer: Timer | None = None
|
self._timer: Timer | None = None
|
||||||
|
self._resume_frame: int | None = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat")
|
yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat")
|
||||||
|
|
@ -178,7 +188,9 @@ class PetitChat(Static):
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._inner = self.query_one(".petit-chat", Static)
|
self._inner = self.query_one(".petit-chat", Static)
|
||||||
if self._do_animate:
|
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:
|
def freeze_animation(self) -> None:
|
||||||
self._freeze_requested = True
|
self._freeze_requested = True
|
||||||
|
|
@ -195,3 +207,35 @@ class PetitChat(Static):
|
||||||
self._dots |= transition["add"]
|
self._dots |= transition["add"]
|
||||||
self._transition_index = (self._transition_index + 1) % len(TRANSITIONS)
|
self._transition_index = (self._transition_index + 1) % len(TRANSITIONS)
|
||||||
self._inner.update(render_braille(self._dots, WIDTH, HEIGHT))
|
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)
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ def classify(
|
||||||
) -> ClassifiedInput:
|
) -> ClassifiedInput:
|
||||||
if value.startswith("&") and commands.has_command("teleport"):
|
if value.startswith("&") and commands.has_command("teleport"):
|
||||||
return Teleport(target=value[1:])
|
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()
|
return SlashCommand()
|
||||||
if value.startswith("/"):
|
if value.startswith("/"):
|
||||||
if (expanded := expand_skill(value)) is not None:
|
if (expanded := expand_skill(value)) is not None:
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,10 @@ class KeyringTokenStorage(TokenStorage):
|
||||||
*,
|
*,
|
||||||
fallback_client_info: OAuthClientInformationFull | None = None,
|
fallback_client_info: OAuthClientInformationFull | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
try:
|
||||||
backend = keyring.get_keyring()
|
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):
|
if isinstance(backend, keyring.backends.fail.Keyring):
|
||||||
raise MCPOAuthHeadlessError(server_alias=alias)
|
raise MCPOAuthHeadlessError(server_alias=alias)
|
||||||
self._alias = alias
|
self._alias = alias
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
|
|
||||||
from vibe.core.config.patch import ConfigPatch
|
from vibe.core.config.patch import ConfigPatch
|
||||||
from vibe.core.config.types import (
|
from vibe.core.config.types import (
|
||||||
MISSING_BACKING_STORE_DATA_FINGERPRINT,
|
|
||||||
ConcurrencyConflictError,
|
ConcurrencyConflictError,
|
||||||
ConflictStrategy,
|
ConflictStrategy,
|
||||||
LayerConfigSnapshot,
|
LayerConfigSnapshot,
|
||||||
|
|
@ -300,11 +299,7 @@ class ConfigLayer[S: BaseModel](ABC):
|
||||||
self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy
|
self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy
|
||||||
) -> _LayerState[S]:
|
) -> _LayerState[S]:
|
||||||
|
|
||||||
if (
|
if state.data is None or state.fingerprint is None:
|
||||||
state.data is None
|
|
||||||
or state.fingerprint is None
|
|
||||||
or state.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT
|
|
||||||
):
|
|
||||||
raise LayerNotLoadedError(self.name)
|
raise LayerNotLoadedError(self.name)
|
||||||
|
|
||||||
match on_conflict:
|
match on_conflict:
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]):
|
||||||
def config_file_path(self) -> Path | None:
|
def config_file_path(self) -> Path | None:
|
||||||
return self._config_file_path
|
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:
|
async def _check_trust(self) -> bool:
|
||||||
await self._find_config_file()
|
await self._find_config_file()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import tomllib
|
import tomllib
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
|
|
@ -21,10 +20,8 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
|
||||||
Pass an explicit ``path`` for testing.
|
Pass an explicit ``path`` for testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LAYER_NAME: ClassVar[str] = "user-toml"
|
def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None:
|
||||||
|
super().__init__(name=name)
|
||||||
def __init__(self, *, path: Path | None = None) -> None:
|
|
||||||
super().__init__(name=self.LAYER_NAME)
|
|
||||||
self._path = path or (VIBE_HOME.path / "config.toml")
|
self._path = path or (VIBE_HOME.path / "config.toml")
|
||||||
|
|
||||||
async def _check_trust(self) -> bool:
|
async def _check_trust(self) -> bool:
|
||||||
|
|
@ -40,8 +37,7 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
|
||||||
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
|
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
|
||||||
|
|
||||||
async def _save_to_store(self, next_config: RawConfig) -> str:
|
async def _save_to_store(self, next_config: RawConfig) -> str:
|
||||||
if not self._path.exists():
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
raise FileNotFoundError(self._path)
|
|
||||||
|
|
||||||
tmp_path: Path | None = None
|
tmp_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from jsonpatch import JsonPatchException, apply_patch
|
from jsonpatch import JsonPatchException, apply_patch
|
||||||
from jsonpointer import JsonPointerException
|
from jsonpointer import JsonPointerException
|
||||||
|
|
@ -11,13 +12,10 @@ from pydantic import ValidationError
|
||||||
from vibe.core.config.builder import ConfigBuilder
|
from vibe.core.config.builder import ConfigBuilder
|
||||||
from vibe.core.config.event_bus import EventBus
|
from vibe.core.config.event_bus import EventBus
|
||||||
from vibe.core.config.layer import ConfigLayer, LayerNotLoadedError, RawConfig
|
from vibe.core.config.layer import ConfigLayer, LayerNotLoadedError, RawConfig
|
||||||
from vibe.core.config.layers.user import UserConfigLayer
|
from vibe.core.config.patch import AddOperationPatch, ConfigPatch, PatchOp
|
||||||
from vibe.core.config.patch import ConfigPatch, PatchOp
|
|
||||||
from vibe.core.config.schema import ConfigSchema
|
from vibe.core.config.schema import ConfigSchema
|
||||||
from vibe.core.config.types import ConfigChangeCallback, ConflictStrategy
|
from vibe.core.config.types import ConfigChangeCallback, ConflictStrategy
|
||||||
|
|
||||||
DEFAULT_PATCH_LAYER_NAME = UserConfigLayer.LAYER_NAME
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigPatchValidationError(Exception):
|
class ConfigPatchValidationError(Exception):
|
||||||
"""Raised when the merged-config preflight rejects a patch."""
|
"""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]:
|
class ConfigOrchestrator[S: ConfigSchema]:
|
||||||
"""Single entry point for config management."""
|
"""Single entry point for config management."""
|
||||||
|
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
self._builder = builder
|
self._builder = builder
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._default_layer_resolver = default_layer_resolver
|
||||||
self._bus = bus if bus is not None else EventBus()
|
self._bus = bus if bus is not None else EventBus()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -45,13 +55,14 @@ class ConfigOrchestrator[S: ConfigSchema]:
|
||||||
*,
|
*,
|
||||||
schema: type[S],
|
schema: type[S],
|
||||||
layers: list[ConfigLayer[RawConfig]],
|
layers: list[ConfigLayer[RawConfig]],
|
||||||
|
default_layer_resolver: DefaultLayerResolver,
|
||||||
bus: EventBus | None = None,
|
bus: EventBus | None = None,
|
||||||
) -> ConfigOrchestrator[S]:
|
) -> ConfigOrchestrator[S]:
|
||||||
"""Build an orchestrator from a schema and an ordered list of layers."""
|
"""Build an orchestrator from a schema and an ordered list of layers."""
|
||||||
builder = ConfigBuilder[S](schema)
|
builder = ConfigBuilder[S](schema)
|
||||||
builder.add_layers(layers)
|
builder.add_layers(layers)
|
||||||
config = await builder.build()
|
config = await builder.build()
|
||||||
instance = cls(builder, config, bus)
|
instance = cls(builder, config, default_layer_resolver, bus)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -68,6 +79,13 @@ class ConfigOrchestrator[S: ConfigSchema]:
|
||||||
"""Force-reload all layers and atomically replace the config snapshot."""
|
"""Force-reload all layers and atomically replace the config snapshot."""
|
||||||
self._config = await self._builder.build(force_load=True)
|
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(
|
async def apply_patch(
|
||||||
self,
|
self,
|
||||||
operations: list[PatchOp],
|
operations: list[PatchOp],
|
||||||
|
|
@ -97,12 +115,14 @@ class ConfigOrchestrator[S: ConfigSchema]:
|
||||||
raise ConfigPatchValidationError() from exc
|
raise ConfigPatchValidationError() from exc
|
||||||
|
|
||||||
operations_by_layer: dict[str, list[PatchOp]] = defaultdict(list)
|
operations_by_layer: dict[str, list[PatchOp]] = defaultdict(list)
|
||||||
|
default_layer_name: str | None = None
|
||||||
for op in operations:
|
for op in operations:
|
||||||
layer_name = (
|
layer_name = op.target_layer_name
|
||||||
op.target_layer_name
|
if layer_name is None:
|
||||||
if op.target_layer_name is not None
|
if default_layer_name is None:
|
||||||
else DEFAULT_PATCH_LAYER_NAME
|
default_layer_name = self._resolve_default_layer_name()
|
||||||
)
|
layer_name = default_layer_name
|
||||||
|
|
||||||
operations_by_layer[layer_name].append(op)
|
operations_by_layer[layer_name].append(op)
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
@ -142,6 +162,15 @@ class ConfigOrchestrator[S: ConfigSchema]:
|
||||||
on_conflict=on_conflict,
|
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(
|
def subscribe(
|
||||||
self, callback: ConfigChangeCallback, *, keys: set[str] | None = None
|
self, callback: ConfigChangeCallback, *, keys: set[str] | None = None
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,20 @@ default_agent = "plan"
|
||||||
|
|
||||||
### MCP Servers
|
### 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
|
```toml
|
||||||
[[mcp_servers]]
|
[[mcp_servers]]
|
||||||
name = "my-server"
|
name = "my-server"
|
||||||
|
|
@ -581,6 +595,10 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
||||||
- `/voice` - Configure voice settings
|
- `/voice` - Configure voice settings
|
||||||
- `/mcp` - Display MCP servers and connector status; pass a server or connector
|
- `/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
|
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 status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`)
|
||||||
- `/mcp login <alias>` - Start OAuth login for an MCP server
|
- `/mcp login <alias>` - Start OAuth login for an MCP server
|
||||||
- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth
|
- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,34 @@ tied to a particular UI layer.
|
||||||
|
|
||||||
from __future__ import annotations
|
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]:
|
def updated_tool_list(tools: list[str], name: str, disabled: bool) -> list[str]:
|
||||||
|
|
@ -73,3 +98,248 @@ def _persist_connector_toggle(
|
||||||
entry["disabled"] = disabled
|
entry["disabled"] = disabled
|
||||||
connectors.append(entry)
|
connectors.append(entry)
|
||||||
VibeConfig.save_updates({"connectors": connectors})
|
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
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,10 @@ def _delete_macos_password(service: str, username: str) -> None:
|
||||||
|
|
||||||
def _set_password(service: str, username: str, password: str) -> None:
|
def _set_password(service: str, username: str, password: str) -> None:
|
||||||
if not _should_use_macos_security():
|
if not _should_use_macos_security():
|
||||||
|
try:
|
||||||
keyring.set_password(service, username, password)
|
keyring.set_password(service, username, password)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise KeyringError("Can't load keyring backend") from exc
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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
|
raise KeyringError("Can't get password from macOS Keychain") from exc
|
||||||
return result.stdout.removesuffix("\n")
|
return result.stdout.removesuffix("\n")
|
||||||
|
|
||||||
|
try:
|
||||||
return keyring.get_password(service, username)
|
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:
|
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)
|
_delete_macos_password(service, username)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
keyring.delete_password(service, username)
|
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:
|
def _migrate_legacy_password(username: str, password: str, legacy_service: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -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
|
# What's new in v2.18.0
|
||||||
|
|
||||||
- **Clipboard image paste**: Paste images directly from clipboard in the TUI on macOS
|
- **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`
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue