From e9428bce233eaae7fd92565a77b6ff4b6508f1ae Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Tue, 10 Mar 2026 16:07:18 +0100 Subject: [PATCH] v2.4.1 (#475) Co-authored-by: Quentin Torroba Co-authored-by: Clement Sirieix Co-authored-by: Kim-Adeline Miguel Co-authored-by: Mistral Vibe --- .vscode/launch.json | 2 +- CHANGELOG.md | 17 + distribution/zed/extension.toml | 14 +- pyproject.toml | 4 +- tests/acp/test_initialize.py | 4 +- tests/acp/test_utils.py | 2 +- tests/conftest.py | 15 +- tests/core/test_config_paths.py | 446 ++++++++++++------ tests/core/test_config_resolution.py | 45 +- tests/core/test_plan_session.py | 2 +- tests/core/test_proxy_setup.py | 2 +- tests/core/test_trusted_folders.py | 3 +- tests/core/test_utils.py | 19 + tests/mock/mock_entrypoint.py | 4 +- tests/onboarding/test_ui_onboarding.py | 2 +- tests/test_agents.py | 33 +- tests/tools/test_websearch.py | 60 ++- uv.lock | 2 +- vibe/__init__.py | 2 +- vibe/acp/entrypoint.py | 25 +- vibe/cli/cli.py | 19 +- vibe/cli/entrypoint.py | 4 +- vibe/cli/textual_ui/app.py | 2 +- .../widgets/chat_input/text_area.py | 6 +- .../cli/textual_ui/widgets/proxy_setup_app.py | 3 +- vibe/cli/textual_ui/widgets/question_app.py | 3 +- vibe/cli/textual_ui/widgets/vscode_compat.py | 26 + .../filesystem_update_cache_repository.py | 2 +- vibe/core/agents/manager.py | 9 +- vibe/core/agents/models.py | 2 +- vibe/core/config/__init__.py | 41 ++ vibe/core/{config.py => config/_settings.py} | 46 +- vibe/core/config/harness_files/__init__.py | 17 + .../config/harness_files/_harness_manager.py | 151 ++++++ vibe/core/config/harness_files/_paths.py | 8 + vibe/core/llm/backend/mistral.py | 9 +- vibe/core/logger.py | 2 +- vibe/core/paths/__init__.py | 31 ++ ...l_config_walk.py => _local_config_walk.py} | 0 .../paths/{global_paths.py => _vibe_home.py} | 6 +- vibe/core/paths/config_paths.py | 63 --- vibe/core/paths/conventions.py | 3 + vibe/core/plan_session.py | 2 +- vibe/core/proxy_setup.py | 2 +- vibe/core/skills/manager.py | 10 +- vibe/core/system_prompt.py | 18 +- vibe/core/tools/builtins/websearch.py | 14 +- vibe/core/tools/manager.py | 10 +- vibe/core/trusted_folders.py | 9 +- vibe/core/utils.py | 5 + vibe/setup/onboarding/__init__.py | 2 +- vibe/setup/onboarding/screens/api_key.py | 2 +- .../trusted_folders/trust_folder_dialog.py | 2 +- vibe/whats_new.md | 7 +- 54 files changed, 877 insertions(+), 362 deletions(-) create mode 100644 tests/core/test_utils.py create mode 100644 vibe/cli/textual_ui/widgets/vscode_compat.py create mode 100644 vibe/core/config/__init__.py rename vibe/core/{config.py => config/_settings.py} (94%) create mode 100644 vibe/core/config/harness_files/__init__.py create mode 100644 vibe/core/config/harness_files/_harness_manager.py create mode 100644 vibe/core/config/harness_files/_paths.py rename vibe/core/paths/{local_config_walk.py => _local_config_walk.py} (100%) rename vibe/core/paths/{global_paths.py => _vibe_home.py} (75%) delete mode 100644 vibe/core/paths/config_paths.py create mode 100644 vibe/core/paths/conventions.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 188bc6a..b63761b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.4.0", + "version": "2.4.1", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e9cc7..9c9bef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.1] - 2026-03-10 + +### Added + +- `HarnessFilesManager` for selective loading of harness files, enabling SDK usage without accessing the file system. + +### Changed + +- Web search tool infers server URL from provider config instead of hardcoded production API +- `ask_user_questions` tool disabled in prompt mode + +### Fixed + +- Space key fix extended to all `Input` widgets (question prompts, proxy setup) in VS Code terminal +- Ruff isort/formatter config conflict resolved (`split-on-trailing-comma` set to `false`) + + ## [2.4.0] - 2026-03-09 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index b794fad..3437b4e 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.4.0" +version = "2.4.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-darwin-aarch64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-darwin-aarch64-2.4.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-darwin-x86_64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-darwin-x86_64-2.4.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-linux-aarch64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-linux-aarch64-2.4.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-linux-x86_64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-linux-x86_64-2.4.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-windows-aarch64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-windows-aarch64-2.4.1.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.0/vibe-acp-windows-x86_64-2.4.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.4.1/vibe-acp-windows-x86_64-2.4.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index ced6006..a31b6d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.4.0" +version = "2.4.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -151,7 +151,7 @@ ban-relative-imports = "all" [tool.ruff.lint.isort] known-first-party = ["vibe"] force-sort-within-sections = true -split-on-trailing-comma = true +split-on-trailing-comma = false combine-as-imports = true force-wrap-aliases = false order-by-type = true diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index df51919..b938066 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -28,7 +28,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.1" ) assert response.auth_methods == [] @@ -52,7 +52,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.4.1" ) assert response.auth_methods is not None diff --git a/tests/acp/test_utils.py b/tests/acp/test_utils.py index 25cf55a..361425f 100644 --- a/tests/acp/test_utils.py +++ b/tests/acp/test_utils.py @@ -1,7 +1,7 @@ from __future__ import annotations from vibe.acp.utils import get_proxy_help_text -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS diff --git a/tests/conftest.py b/tests/conftest.py index 7095392..5ad57e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,9 +23,11 @@ from vibe.core.config import ( SessionLoggingConfig, VibeConfig, ) +from vibe.core.config.harness_files import ( + init_harness_files_manager, + reset_harness_files_manager, +) from vibe.core.llm.types import BackendLike -from vibe.core.paths import global_paths -from vibe.core.paths.config_paths import unlock_config_paths def get_base_config() -> dict[str, Any]: @@ -69,13 +71,16 @@ def config_dir( config_file = config_dir / "config.toml" config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8") - monkeypatch.setattr(global_paths, "_DEFAULT_VIBE_HOME", config_dir) + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", config_dir) return config_dir @pytest.fixture(autouse=True) -def _unlock_config_paths(): - unlock_config_paths() +def _init_harness_files_manager(): + reset_harness_files_manager() + init_harness_files_manager("user", "project") + yield + reset_harness_files_manager() @pytest.fixture(autouse=True) diff --git a/tests/core/test_config_paths.py b/tests/core/test_config_paths.py index 31f5283..f614aba 100644 --- a/tests/core/test_config_paths.py +++ b/tests/core/test_config_paths.py @@ -1,184 +1,334 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import patch -from vibe.core.paths.config_paths import ( - discover_local_agents_dirs, - discover_local_skills_dirs, - discover_local_tools_dirs, -) +import pytest + +from vibe.core.config.harness_files import HarnessFilesManager +from vibe.core.trusted_folders import trusted_folders_manager -class TestDiscoverLocalSkillsDirs: - def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "skills").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = False - assert discover_local_skills_dirs(tmp_path) == [] - - def test_returns_empty_list_when_trusted_but_no_skills_dirs( - self, tmp_path: Path +class TestTrustedWorkdir: + def test_returns_none_when_project_not_in_sources( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - assert discover_local_skills_dirs(tmp_path) == [] + monkeypatch.chdir(tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.trusted_workdir is None - def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None: + def test_returns_none_when_not_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.trusted_workdir is None + + def test_returns_cwd_when_project_in_sources_and_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.trusted_workdir == tmp_path + + +class TestProjectToolsDirs: + def test_returns_empty_when_project_not_in_sources( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.project_tools_dirs == [] + + def test_returns_empty_when_not_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False) + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [] + + def test_returns_empty_when_tools_dir_does_not_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [] + + def test_returns_path_when_tools_dir_exists_and_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + tools_dir = tmp_path / ".vibe" / "tools" + tools_dir.mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [tools_dir] + + def test_ignores_tools_when_file_not_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe").mkdir() + (tmp_path / ".vibe" / "tools").write_text("", encoding="utf-8") + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [] + + def test_finds_tools_dirs_recursively( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [ + tmp_path / ".vibe" / "tools", + tmp_path / "sub" / ".vibe" / "tools", + ] + + def test_does_not_descend_into_ignored_dirs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_tools_dirs == [tmp_path / ".vibe" / "tools"] + + +class TestProjectAgentsDirs: + def test_returns_empty_when_project_not_in_sources( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.project_agents_dirs == [] + + def test_returns_empty_when_not_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False) + (tmp_path / ".vibe" / "agents").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [] + + def test_returns_empty_when_agents_dir_does_not_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [] + + def test_returns_path_when_agents_dir_exists_and_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + agents_dir = tmp_path / ".vibe" / "agents" + agents_dir.mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [agents_dir] + + def test_ignores_agents_when_file_not_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe").mkdir() + (tmp_path / ".vibe" / "agents").write_text("", encoding="utf-8") + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [] + + def test_finds_agents_dirs_recursively( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe" / "agents").mkdir(parents=True) + (tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [ + tmp_path / ".vibe" / "agents", + tmp_path / "sub" / "deep" / ".vibe" / "agents", + ] + + def test_does_not_descend_into_ignored_dirs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + (tmp_path / ".vibe" / "agents").mkdir(parents=True) + (tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_agents_dirs == [tmp_path / ".vibe" / "agents"] + + +class TestUserToolsDirs: + def test_returns_empty_when_user_not_in_sources(self) -> None: + mgr = HarnessFilesManager(sources=("project",)) + assert mgr.user_tools_dirs == [] + + def test_returns_empty_when_dir_does_not_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_tools_dirs == [] + + def test_returns_path_when_user_in_sources_and_dir_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_tools_dirs == [tools_dir] + + +class TestUserSkillsDirs: + def test_returns_empty_when_user_not_in_sources(self) -> None: + mgr = HarnessFilesManager(sources=("project",)) + assert mgr.user_skills_dirs == [] + + def test_returns_empty_when_dir_does_not_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_skills_dirs == [] + + def test_returns_path_when_user_in_sources_and_dir_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_skills_dirs == [skills_dir] + + +class TestUserAgentsDirs: + def test_returns_empty_when_user_not_in_sources(self) -> None: + mgr = HarnessFilesManager(sources=("project",)) + assert mgr.user_agents_dirs == [] + + def test_returns_empty_when_dir_does_not_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_agents_dirs == [] + + def test_returns_path_when_user_in_sources_and_dir_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", tmp_path) + agents_dir = tmp_path / "agents" + agents_dir.mkdir() + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.user_agents_dirs == [agents_dir] + + +class TestProjectSkillsDirs: + def test_returns_empty_list_when_no_skills_dirs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [] + + def test_returns_vibe_skills_only_when_only_it_exists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) vibe_skills = tmp_path / ".vibe" / "skills" vibe_skills.mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [vibe_skills] + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [vibe_skills] def test_returns_agents_skills_only_when_only_it_exists( - self, tmp_path: Path + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) agents_skills = tmp_path / ".agents" / "skills" agents_skills.mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [agents_skills] + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [agents_skills] - def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None: + def test_returns_both_in_order_when_both_exist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) vibe_skills = tmp_path / ".vibe" / "skills" agents_skills = tmp_path / ".agents" / "skills" vibe_skills.mkdir(parents=True) agents_skills.mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [vibe_skills, agents_skills] + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [vibe_skills, agents_skills] - def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None: + def test_ignores_vibe_skills_when_file_not_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) (tmp_path / ".vibe").mkdir() (tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8") - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [] + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [] + + def test_returns_empty_when_project_not_in_sources( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + vibe_skills = tmp_path / ".vibe" / "skills" + vibe_skills.mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.project_skills_dirs == [] + + def test_returns_empty_when_not_trusted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: False) + vibe_skills = tmp_path / ".vibe" / "skills" + vibe_skills.mkdir(parents=True) + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [] def test_finds_skills_dirs_recursively_in_trusted_folder( - self, tmp_path: Path + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) (tmp_path / ".vibe" / "skills").mkdir(parents=True) (tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True) (tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [ + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [ tmp_path / ".vibe" / "skills", tmp_path / "sub" / ".agents" / "skills", tmp_path / "sub" / "deep" / ".vibe" / "skills", ] - def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None: + def test_does_not_descend_into_ignored_dirs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) (tmp_path / ".vibe" / "skills").mkdir(parents=True) (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_skills_dirs(tmp_path) - assert result == [tmp_path / ".vibe" / "skills"] - - -class TestDiscoverLocalToolsDirs: - def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = False - assert discover_local_tools_dirs(tmp_path) == [] - - def test_returns_empty_list_when_trusted_but_no_tools_dir( - self, tmp_path: Path - ) -> None: - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - assert discover_local_tools_dirs(tmp_path) == [] - - def test_returns_tools_dir_when_exists(self, tmp_path: Path) -> None: - vibe_tools = tmp_path / ".vibe" / "tools" - vibe_tools.mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_tools_dirs(tmp_path) - assert result == [vibe_tools] - - def test_ignores_tools_when_file_not_dir(self, tmp_path: Path) -> None: - (tmp_path / ".vibe").mkdir() - (tmp_path / ".vibe" / "tools").write_text("", encoding="utf-8") - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_tools_dirs(tmp_path) - assert result == [] - - def test_finds_tools_dirs_recursively(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_tools_dirs(tmp_path) - assert result == [ - tmp_path / ".vibe" / "tools", - tmp_path / "sub" / ".vibe" / "tools", - ] - - def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_tools_dirs(tmp_path) - assert result == [tmp_path / ".vibe" / "tools"] - - -class TestDiscoverLocalAgentsDirs: - def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "agents").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = False - assert discover_local_agents_dirs(tmp_path) == [] - - def test_returns_empty_list_when_trusted_but_no_agents_dir( - self, tmp_path: Path - ) -> None: - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - assert discover_local_agents_dirs(tmp_path) == [] - - def test_returns_agents_dir_when_exists(self, tmp_path: Path) -> None: - vibe_agents = tmp_path / ".vibe" / "agents" - vibe_agents.mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_agents_dirs(tmp_path) - assert result == [vibe_agents] - - def test_ignores_agents_when_file_not_dir(self, tmp_path: Path) -> None: - (tmp_path / ".vibe").mkdir() - (tmp_path / ".vibe" / "agents").write_text("", encoding="utf-8") - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_agents_dirs(tmp_path) - assert result == [] - - def test_finds_agents_dirs_recursively(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "agents").mkdir(parents=True) - (tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_agents_dirs(tmp_path) - assert result == [ - tmp_path / ".vibe" / "agents", - tmp_path / "sub" / "deep" / ".vibe" / "agents", - ] - - def test_does_not_descend_into_ignored_dirs(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "agents").mkdir(parents=True) - (tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True) - with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: - mock_tfm.is_trusted.return_value = True - result = discover_local_agents_dirs(tmp_path) - assert result == [tmp_path / ".vibe" / "agents"] + mgr = HarnessFilesManager(sources=("user", "project")) + assert mgr.project_skills_dirs == [tmp_path / ".vibe" / "skills"] diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 442cc4f..41680a9 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -4,8 +4,12 @@ from pathlib import Path import pytest -from vibe.core.paths.config_paths import CONFIG_FILE -from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, VIBE_HOME +from vibe.core.config.harness_files import ( + HarnessFilesManager, + init_harness_files_manager, + reset_harness_files_manager, +) +from vibe.core.paths import VIBE_HOME from vibe.core.trusted_folders import trusted_folders_manager @@ -21,11 +25,18 @@ class TestResolveConfigFile: monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) - assert CONFIG_FILE.path == local_config - assert CONFIG_FILE.path.is_file() - assert CONFIG_FILE.path.read_text(encoding="utf-8") == 'active_model = "test"' + reset_harness_files_manager() + init_harness_files_manager("user", "project") + from vibe.core.config.harness_files import get_harness_files_manager - def test_resolves_local_config_when_exists_and_folder_is_not_trusted( + mgr = get_harness_files_manager() + resolved = mgr.config_file + assert resolved is not None + assert resolved == local_config + assert resolved.is_file() + assert resolved.read_text(encoding="utf-8") == 'active_model = "test"' + + def test_resolves_global_config_when_folder_is_not_trusted( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -34,7 +45,12 @@ class TestResolveConfigFile: local_config = local_config_dir / "config.toml" local_config.write_text('active_model = "test"', encoding="utf-8") - assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path + reset_harness_files_manager() + init_harness_files_manager("user", "project") + from vibe.core.config.harness_files import get_harness_files_manager + + mgr = get_harness_files_manager() + assert mgr.config_file == VIBE_HOME.path / "config.toml" def test_falls_back_to_global_config_when_local_missing( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -43,7 +59,12 @@ class TestResolveConfigFile: # Ensure no local config exists assert not (tmp_path / ".vibe" / "config.toml").exists() - assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path + reset_harness_files_manager() + init_harness_files_manager("user", "project") + from vibe.core.config.harness_files import get_harness_files_manager + + mgr = get_harness_files_manager() + assert mgr.config_file == VIBE_HOME.path / "config.toml" def test_respects_vibe_home_env_var( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -51,3 +72,11 @@ class TestResolveConfigFile: assert VIBE_HOME.path != tmp_path monkeypatch.setenv("VIBE_HOME", str(tmp_path)) assert VIBE_HOME.path == tmp_path + + def test_returns_none_when_no_sources(self) -> None: + mgr = HarnessFilesManager(sources=()) + assert mgr.config_file is None + + def test_user_only_returns_global_config(self) -> None: + mgr = HarnessFilesManager(sources=("user",)) + assert mgr.config_file == VIBE_HOME.path / "config.toml" diff --git a/tests/core/test_plan_session.py b/tests/core/test_plan_session.py index b4f4a7d..bf34bd8 100644 --- a/tests/core/test_plan_session.py +++ b/tests/core/test_plan_session.py @@ -1,6 +1,6 @@ from __future__ import annotations -from vibe.core.paths.global_paths import PLANS_DIR +from vibe.core.paths import PLANS_DIR from vibe.core.plan_session import PlanSession diff --git a/tests/core/test_proxy_setup.py b/tests/core/test_proxy_setup.py index 7bd3859..9cb0c62 100644 --- a/tests/core/test_proxy_setup.py +++ b/tests/core/test_proxy_setup.py @@ -2,7 +2,7 @@ from __future__ import annotations import pytest -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.proxy_setup import ( SUPPORTED_PROXY_VARS, ProxySetupError, diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py index 5e92d2a..aaeebe6 100644 --- a/tests/core/test_trusted_folders.py +++ b/tests/core/test_trusted_folders.py @@ -7,9 +7,8 @@ from unittest.mock import patch import pytest import tomli_w -from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE +from vibe.core.paths import AGENTS_MD_FILENAMES, TRUSTED_FOLDERS_FILE from vibe.core.trusted_folders import ( - AGENTS_MD_FILENAMES, TrustedFoldersManager, has_agents_md_file, has_trustable_content, diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py new file mode 100644 index 0000000..ef083cd --- /dev/null +++ b/tests/core/test_utils.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import pytest + +from vibe.core.utils import get_server_url_from_api_base + + +@pytest.mark.parametrize( + ("api_base", "expected"), + [ + ("https://api.mistral.ai/v1", "https://api.mistral.ai"), + ("https://on-prem.example.com/v1", "https://on-prem.example.com"), + ("http://localhost:8080/v2", "http://localhost:8080"), + ("not-a-url", None), + ("ftp://example.com/v1", None), + ], +) +def test_get_server_url_from_api_base(api_base, expected): + assert get_server_url_from_api_base(api_base) == expected diff --git a/tests/mock/mock_entrypoint.py b/tests/mock/mock_entrypoint.py index 29db35c..57d8973 100644 --- a/tests/mock/mock_entrypoint.py +++ b/tests/mock/mock_entrypoint.py @@ -14,10 +14,10 @@ from unittest.mock import patch from pydantic import ValidationError -from vibe.core.paths.config_paths import unlock_config_paths +from vibe.core.config.harness_files import init_harness_files_manager if __name__ == "__main__": - unlock_config_paths() + init_harness_files_manager("user", "project") from tests import TESTS_ROOT from tests.mock.utils import MOCK_DATA_ENV_VAR diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 379509b..e12201d 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -6,7 +6,7 @@ import pytest from textual.pilot import Pilot from textual.widgets import Input -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.setup.onboarding import OnboardingApp from vibe.setup.onboarding.screens.api_key import ApiKeyScreen diff --git a/tests/test_agents.py b/tests/test_agents.py index 4f1ffae..7867f51 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -16,8 +16,7 @@ from vibe.core.agents.models import ( _deep_merge, ) from vibe.core.config import VibeConfig -from vibe.core.paths.config_paths import ConfigPath -from vibe.core.paths.global_paths import GlobalPath +from vibe.core.config.harness_files import HarnessFilesManager from vibe.core.tools.base import ToolPermission from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role @@ -176,11 +175,18 @@ class TestAgentApplyToConfig: global_prompts.mkdir(parents=True) (global_prompts / "cc.md").write_text("Global custom prompt") + class _MockManager(HarnessFilesManager): + @property + def project_prompts_dirs(self) -> list[Path]: + return [project_prompts] + + @property + def user_prompts_dirs(self) -> list[Path]: + return [global_prompts] + + mock_manager = _MockManager(sources=("user",)) monkeypatch.setattr( - "vibe.core.config.PROMPTS_DIR", ConfigPath(lambda: project_prompts) - ) - monkeypatch.setattr( - "vibe.core.config.GLOBAL_PROMPTS_DIR", GlobalPath(lambda: global_prompts) + "vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager ) base = VibeConfig(include_project_context=False, include_prompt_detail=False) @@ -555,11 +561,18 @@ class TestAgentLoopInitialization: custom_prompt_content = "CUSTOM_AGENT_PROMPT_MARKER" (global_prompts / "custom_agent.md").write_text(custom_prompt_content) + class _MockManager(HarnessFilesManager): + @property + def project_prompts_dirs(self) -> list[Path]: + return [project_prompts] + + @property + def user_prompts_dirs(self) -> list[Path]: + return [global_prompts] + + mock_manager = _MockManager(sources=("user",)) monkeypatch.setattr( - "vibe.core.config.PROMPTS_DIR", ConfigPath(lambda: project_prompts) - ) - monkeypatch.setattr( - "vibe.core.config.GLOBAL_PROMPTS_DIR", GlobalPath(lambda: global_prompts) + "vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager ) custom_agent = AgentProfile( diff --git a/tests/tools/test_websearch.py b/tests/tools/test_websearch.py index 66becce..67731f5 100644 --- a/tests/tools/test_websearch.py +++ b/tests/tools/test_websearch.py @@ -1,12 +1,13 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import mistralai import pytest from tests.mock.utils import collect_result -from vibe.core.tools.base import BaseToolState, ToolError +from vibe.core.config import Backend, ProviderConfig +from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig @@ -151,6 +152,61 @@ async def test_run_sdk_error_wrapped(websearch): await collect_result(websearch.run(WebSearchArgs(query="test"))) +def test_resolve_server_url_no_ctx(websearch): + assert websearch._resolve_server_url(None) is None + + +def test_resolve_server_url_no_agent_manager(websearch): + ctx = InvokeContext(tool_call_id="t1", agent_manager=None) + assert websearch._resolve_server_url(ctx) is None + + +def test_resolve_server_url_with_mistral_provider(websearch): + config = MagicMock() + config.providers = [ + ProviderConfig( + name="mistral", + api_base="https://on-prem.example.com/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + agent_manager = MagicMock() + agent_manager.config = config + + ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager) + assert websearch._resolve_server_url(ctx) == "https://on-prem.example.com" + + +def test_resolve_server_url_with_default_provider(websearch): + config = MagicMock() + config.providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + agent_manager = MagicMock() + agent_manager.config = config + + ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager) + assert websearch._resolve_server_url(ctx) == "https://api.mistral.ai" + + +def test_resolve_server_url_no_mistral_provider(websearch): + config = MagicMock() + config.providers = [ + ProviderConfig(name="llamacpp", api_base="http://127.0.0.1:8080/v1") + ] + agent_manager = MagicMock() + agent_manager.config = config + + ctx = InvokeContext(tool_call_id="t1", agent_manager=agent_manager) + assert websearch._resolve_server_url(ctx) is None + + def test_is_available_with_key(monkeypatch): monkeypatch.setenv("MISTRAL_API_KEY", "key") assert WebSearch.is_available() is True diff --git a/uv.lock b/uv.lock index 506ffac..fc48d63 100644 --- a/uv.lock +++ b/uv.lock @@ -779,7 +779,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.4.0" +version = "2.4.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index b44f038..b71cb46 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.4.0" +__version__ = "2.4.1" diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py index 60a1c90..db6a257 100644 --- a/vibe/acp/entrypoint.py +++ b/vibe/acp/entrypoint.py @@ -5,10 +5,16 @@ from dataclasses import dataclass import os import sys +import tomli_w + from vibe import __version__ from vibe.core.config import VibeConfig +from vibe.core.config.harness_files import ( + get_harness_files_manager, + init_harness_files_manager, +) from vibe.core.logger import logger -from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths +from vibe.core.paths import HISTORY_FILE # Configure line buffering for subprocess communication sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue] @@ -32,17 +38,22 @@ def parse_arguments() -> Arguments: def bootstrap_config_files() -> None: - if not CONFIG_FILE.path.exists(): + mgr = get_harness_files_manager() + config_file = mgr.user_config_file + if not config_file.exists(): try: - VibeConfig.save_updates(VibeConfig.create_default()) + config_file.parent.mkdir(parents=True, exist_ok=True) + with config_file.open("wb") as f: + tomli_w.dump(VibeConfig.create_default(), f) except Exception as e: logger.error(f"Could not create default config file: {e}") raise - if not HISTORY_FILE.path.exists(): + history_file = HISTORY_FILE.path + if not history_file.exists(): try: - HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True) - HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8") + history_file.parent.mkdir(parents=True, exist_ok=True) + history_file.write_text("Hello Vibe!\n", "utf-8") except Exception as e: logger.error(f"Could not create history file: {e}") raise @@ -64,7 +75,7 @@ def handle_debug_mode() -> None: def main() -> None: handle_debug_mode() - unlock_config_paths() + init_harness_files_manager("user", "project") from vibe.acp.acp_agent_loop import run_acp_server from vibe.setup.onboarding import run_onboarding diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index 6799ff4..e06aaa6 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -5,6 +5,7 @@ from pathlib import Path import sys from rich import print as rprint +import tomli_w from vibe import __version__ from vibe.cli.textual_ui.app import run_textual_ui @@ -16,8 +17,9 @@ from vibe.core.config import ( VibeConfig, load_dotenv_values, ) +from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger -from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE +from vibe.core.paths import HISTORY_FILE from vibe.core.programmatic import run_programmatic from vibe.core.session.session_loader import SessionLoader from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role @@ -61,16 +63,21 @@ def load_config_or_exit() -> VibeConfig: def bootstrap_config_files() -> None: - if not CONFIG_FILE.path.exists(): + mgr = get_harness_files_manager() + config_file = mgr.user_config_file + if not config_file.exists(): try: - VibeConfig.save_updates(VibeConfig.create_default()) + config_file.parent.mkdir(parents=True, exist_ok=True) + with config_file.open("wb") as f: + tomli_w.dump(VibeConfig.create_default(), f) except Exception as e: rprint(f"[yellow]Could not create default config file: {e}[/]") - if not HISTORY_FILE.path.exists(): + history_file = HISTORY_FILE.path + if not history_file.exists(): try: - HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True) - HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8") + history_file.parent.mkdir(parents=True, exist_ok=True) + history_file.write_text("Hello Vibe!\n", "utf-8") except Exception as e: rprint(f"[yellow]Could not create history file: {e}[/]") diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index a656791..0910a28 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -9,7 +9,7 @@ from rich import print as rprint from vibe import __version__ from vibe.core.agents.models import BuiltinAgentName -from vibe.core.paths.config_paths import unlock_config_paths +from vibe.core.config.harness_files import init_harness_files_manager from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager from vibe.setup.trusted_folders.trust_folder_dialog import ( TrustDialogQuitException, @@ -152,7 +152,7 @@ def main() -> None: is_interactive = args.prompt is None if is_interactive: check_and_resolve_trusted_folder() - unlock_config_paths() + init_harness_files_manager("user", "project") from vibe.cli.cli import run_cli diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 23756dd..7589601 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -92,7 +92,7 @@ from vibe.core.agents import AgentProfile from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.config import Backend, VibeConfig from vibe.core.logger import logger -from vibe.core.paths.config_paths import HISTORY_FILE +from vibe.core.paths import HISTORY_FILE from vibe.core.session.session_loader import SessionLoader from vibe.core.teleport.types import ( TeleportAuthCompleteEvent, diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index edec6c2..2fdf95a 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -12,6 +12,7 @@ from vibe.cli.textual_ui.external_editor import ExternalEditor from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, ) +from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space InputMode = Literal["!", "/", ">", "&"] @@ -267,10 +268,7 @@ class ChatTextArea(TextArea): event.stop() return - # Work around VS Code 1.110+ sending space as CSI u (\x1b[32u), - # which Textual parses as Key("space", character=None, is_printable=False). - if event.key == "space" and event.character is None: - event.character = " " + patch_vscode_space(event) await super()._on_key(event) self._mark_cursor_moved_if_needed() diff --git a/vibe/cli/textual_ui/widgets/proxy_setup_app.py b/vibe/cli/textual_ui/widgets/proxy_setup_app.py index c4076fb..99783d0 100644 --- a/vibe/cli/textual_ui/widgets/proxy_setup_app.py +++ b/vibe/cli/textual_ui/widgets/proxy_setup_app.py @@ -10,6 +10,7 @@ from textual.message import Message from textual.widgets import Input, Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput from vibe.core.proxy_setup import ( SUPPORTED_PROXY_VARS, get_current_proxy_settings, @@ -48,7 +49,7 @@ class ProxySetupApp(Container): yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line") initial_value = self.initial_values.get(key) or "" - input_widget = Input( + input_widget = VscodeCompatInput( value=initial_value, placeholder=description, id=f"proxy-input-{key}", diff --git a/vibe/cli/textual_ui/widgets/question_app.py b/vibe/cli/textual_ui/widgets/question_app.py index 03f7d12..64c7eb4 100644 --- a/vibe/cli/textual_ui/widgets/question_app.py +++ b/vibe/cli/textual_ui/widgets/question_app.py @@ -13,6 +13,7 @@ from textual.widgets import Input from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput if TYPE_CHECKING: from vibe.core.tools.builtins.ask_user_question import ( @@ -134,7 +135,7 @@ class QuestionApp(Container): with Horizontal(classes="question-other-row"): self.other_prefix = NoMarkupStatic("", classes="question-other-prefix") yield self.other_prefix - self.other_input = Input( + self.other_input = VscodeCompatInput( placeholder="Type your answer...", classes="question-other-input" ) yield self.other_input diff --git a/vibe/cli/textual_ui/widgets/vscode_compat.py b/vibe/cli/textual_ui/widgets/vscode_compat.py new file mode 100644 index 0000000..10460e7 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/vscode_compat.py @@ -0,0 +1,26 @@ +"""Workarounds for VS Code terminal quirks affecting Textual widgets.""" + +from __future__ import annotations + +from textual import events +from textual.widgets import Input + + +def patch_vscode_space(event: events.Key) -> None: + """Patch space key events sent as CSI u by VS Code 1.110+. + + VS Code encodes space as ``\\x1b[32u`` (CSI u), which Textual parses as + ``Key("space", character=None, is_printable=False)``. Input widgets then + silently drop the keystroke because there is no printable character. + Assigning ``event.character = " "`` restores normal behaviour. + """ + if event.key == "space" and event.character is None: + event.character = " " + + +class VscodeCompatInput(Input): + """``Input`` subclass that handles the VS Code CSI-u space quirk.""" + + async def _on_key(self, event: events.Key) -> None: + patch_vscode_space(event) + await super()._on_key(event) diff --git a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py index 5a85408..927b518 100644 --- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -8,7 +8,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import ( UpdateCache, UpdateCacheRepository, ) -from vibe.core.paths.global_paths import VIBE_HOME +from vibe.core.paths import VIBE_HOME class FileSystemUpdateCacheRepository(UpdateCacheRepository): diff --git a/vibe/core/agents/manager.py b/vibe/core/agents/manager.py index 2d1a346..e6b9c23 100644 --- a/vibe/core/agents/manager.py +++ b/vibe/core/agents/manager.py @@ -10,9 +10,8 @@ from vibe.core.agents.models import ( AgentType, BuiltinAgentName, ) +from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger -from vibe.core.paths.config_paths import discover_local_agents_dirs -from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR from vibe.core.utils import name_matches if TYPE_CHECKING: @@ -88,9 +87,9 @@ class AgentManager: for path in config.agent_paths: if path.is_dir(): paths.append(path) - paths.extend(discover_local_agents_dirs(Path.cwd())) - if GLOBAL_AGENTS_DIR.path.is_dir(): - paths.append(GLOBAL_AGENTS_DIR.path) + mgr = get_harness_files_manager() + paths.extend(mgr.project_agents_dirs) + paths.extend(mgr.user_agents_dirs) unique: list[Path] = [] for p in paths: rp = p.resolve() diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index 85ead72..eb3ca71 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -6,7 +6,7 @@ from pathlib import Path import tomllib from typing import TYPE_CHECKING, Any -from vibe.core.paths.global_paths import PLANS_DIR +from vibe.core.paths import PLANS_DIR if TYPE_CHECKING: from vibe.core.config import VibeConfig diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py new file mode 100644 index 0000000..6bcfb9c --- /dev/null +++ b/vibe/core/config/__init__.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from vibe.core.config._settings import ( + DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_MODELS, + DEFAULT_PROVIDERS, + Backend, + MCPHttp, + MCPServer, + MCPStdio, + MCPStreamableHttp, + MissingAPIKeyError, + MissingPromptFileError, + ModelConfig, + ProjectContextConfig, + ProviderConfig, + SessionLoggingConfig, + TomlFileSettingsSource, + VibeConfig, + load_dotenv_values, +) + +__all__ = [ + "DEFAULT_MISTRAL_API_ENV_KEY", + "DEFAULT_MODELS", + "DEFAULT_PROVIDERS", + "Backend", + "MCPHttp", + "MCPServer", + "MCPStdio", + "MCPStreamableHttp", + "MissingAPIKeyError", + "MissingPromptFileError", + "ModelConfig", + "ProjectContextConfig", + "ProviderConfig", + "SessionLoggingConfig", + "TomlFileSettingsSource", + "VibeConfig", + "load_dotenv_values", +] diff --git a/vibe/core/config.py b/vibe/core/config/_settings.py similarity index 94% rename from vibe/core/config.py rename to vibe/core/config/_settings.py index d160f14..87521e6 100644 --- a/vibe/core/config.py +++ b/vibe/core/config/_settings.py @@ -20,12 +20,8 @@ from pydantic_settings import ( ) import tomli_w -from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPTS_DIR -from vibe.core.paths.global_paths import ( - GLOBAL_ENV_FILE, - GLOBAL_PROMPTS_DIR, - SESSION_LOG_DIR, -) +from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.prompts import SystemPrompt from vibe.core.tools.base import BaseToolConfig @@ -55,20 +51,14 @@ class MissingAPIKeyError(RuntimeError): class MissingPromptFileError(RuntimeError): - def __init__( - self, system_prompt_id: str, prompt_dir: str, global_prompt_dir: str - ) -> None: - extra_global_prompt_dir = ( - f" or {global_prompt_dir}" if global_prompt_dir != prompt_dir else "" - ) - + def __init__(self, system_prompt_id: str, *prompt_dirs: str) -> None: + dirs_str = " or ".join(prompt_dirs) if prompt_dirs else "" super().__init__( f"Invalid system_prompt_id value: '{system_prompt_id}'. " f"Must be one of the available prompts ({', '.join(f'{p.name.lower()}' for p in SystemPrompt)}), " - f"or correspond to a .md file in {prompt_dir}{extra_global_prompt_dir}" + f"or correspond to a .md file in {dirs_str}" ) self.system_prompt_id = system_prompt_id - self.prompt_dir = prompt_dir class TomlFileSettingsSource(PydanticBaseSettingsSource): @@ -77,7 +67,9 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource): self.toml_data = self._load_toml() def _load_toml(self) -> dict[str, Any]: - file = CONFIG_FILE.path + file = get_harness_files_manager().config_file + if file is None: + return {} try: with file.open("rb") as f: return tomllib.load(f) @@ -422,7 +414,9 @@ class VibeConfig(BaseSettings): except KeyError: pass - for current_prompt_dir in [PROMPTS_DIR.path, GLOBAL_PROMPTS_DIR.path]: + mgr = get_harness_files_manager() + prompt_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs + for current_prompt_dir in prompt_dirs: custom_sp_path = (current_prompt_dir / self.system_prompt_id).with_suffix( ".md" ) @@ -430,7 +424,7 @@ class VibeConfig(BaseSettings): return custom_sp_path.read_text() raise MissingPromptFileError( - self.system_prompt_id, str(PROMPTS_DIR.path), str(GLOBAL_PROMPTS_DIR.path) + self.system_prompt_id, *(str(d) for d in prompt_dirs) ) def get_active_model(self) -> ModelConfig: @@ -533,7 +527,8 @@ class VibeConfig(BaseSettings): @classmethod def save_updates(cls, updates: dict[str, Any]) -> None: - CONFIG_DIR.path.mkdir(parents=True, exist_ok=True) + if not get_harness_files_manager().persist_allowed: + return current_config = TomlFileSettingsSource(cls).toml_data def deep_merge(target: dict, source: dict) -> None: @@ -563,7 +558,12 @@ class VibeConfig(BaseSettings): @classmethod def dump_config(cls, config: dict[str, Any]) -> None: - with CONFIG_FILE.path.open("wb") as f: + mgr = get_harness_files_manager() + if not mgr.persist_allowed: + return + target = mgr.config_file or mgr.user_config_file + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as f: tomli_w.dump(config, f) @classmethod @@ -577,11 +577,7 @@ class VibeConfig(BaseSettings): @classmethod def create_default(cls) -> dict[str, Any]: - try: - config = cls() - except MissingAPIKeyError: - config = cls.model_construct() - + config = cls.model_construct() config_dict = config.model_dump(mode="json", exclude_none=True) from vibe.core.tools.manager import ToolManager diff --git a/vibe/core/config/harness_files/__init__.py b/vibe/core/config/harness_files/__init__.py new file mode 100644 index 0000000..7bcd334 --- /dev/null +++ b/vibe/core/config/harness_files/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from vibe.core.config.harness_files._harness_manager import ( + FileSource, + HarnessFilesManager, + get_harness_files_manager, + init_harness_files_manager, + reset_harness_files_manager, +) + +__all__ = [ + "FileSource", + "HarnessFilesManager", + "get_harness_files_manager", + "init_harness_files_manager", + "reset_harness_files_manager", +] diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py new file mode 100644 index 0000000..9fa7a7a --- /dev/null +++ b/vibe/core/config/harness_files/_harness_manager.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from vibe.core.config.harness_files._paths import ( + GLOBAL_AGENTS_DIR, + GLOBAL_PROMPTS_DIR, + GLOBAL_SKILLS_DIR, + GLOBAL_TOOLS_DIR, +) +from vibe.core.paths import AGENTS_MD_FILENAMES, VIBE_HOME, walk_local_config_dirs_all +from vibe.core.trusted_folders import trusted_folders_manager + +FileSource = Literal["user", "project"] + + +@dataclass(frozen=True) +class HarnessFilesManager: + sources: tuple[FileSource, ...] = ("user",) + cwd: Path | None = field(default=None) + + @property + def _effective_cwd(self) -> Path: + return self.cwd or Path.cwd() + + @property + def trusted_workdir(self) -> Path | None: + """Return cwd if project source is enabled and trusted, else None.""" + if "project" not in self.sources: + return None + cwd = self._effective_cwd + if trusted_folders_manager.is_trusted(cwd) is not True: + return None + return cwd + + @property + def config_file(self) -> Path | None: + workdir = self.trusted_workdir + if workdir is not None: + candidate = workdir / ".vibe" / "config.toml" + if candidate.is_file(): + return candidate + if "user" in self.sources: + return VIBE_HOME.path / "config.toml" + return None + + @property + def persist_allowed(self) -> bool: + return "user" in self.sources + + @property + def user_tools_dirs(self) -> list[Path]: + if "user" not in self.sources: + return [] + d = GLOBAL_TOOLS_DIR.path + return [d] if d.is_dir() else [] + + @property + def user_skills_dirs(self) -> list[Path]: + if "user" not in self.sources: + return [] + d = GLOBAL_SKILLS_DIR.path + return [d] if d.is_dir() else [] + + @property + def user_agents_dirs(self) -> list[Path]: + if "user" not in self.sources: + return [] + d = GLOBAL_AGENTS_DIR.path + return [d] if d.is_dir() else [] + + def _walk_project_dirs( + self, + ) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: + workdir = self.trusted_workdir + if workdir is None: + return ((), (), ()) + return walk_local_config_dirs_all(workdir) + + @property + def project_tools_dirs(self) -> list[Path]: + return list(self._walk_project_dirs()[0]) + + @property + def project_skills_dirs(self) -> list[Path]: + return list(self._walk_project_dirs()[1]) + + @property + def project_agents_dirs(self) -> list[Path]: + return list(self._walk_project_dirs()[2]) + + @property + def user_config_file(self) -> Path: + return VIBE_HOME.path / "config.toml" + + @property + def project_prompts_dirs(self) -> list[Path]: + workdir = self.trusted_workdir + if workdir is None: + return [] + candidate = workdir / ".vibe" / "prompts" + return [candidate] if candidate.is_dir() else [] + + @property + def user_prompts_dirs(self) -> list[Path]: + if "user" not in self.sources: + return [] + d = GLOBAL_PROMPTS_DIR.path + return [d] if d.is_dir() else [] + + def load_project_doc(self, max_bytes: int) -> str: + workdir = self.trusted_workdir + if workdir is None: + return "" + for name in AGENTS_MD_FILENAMES: + path = workdir / name + try: + return path.read_text("utf-8", errors="ignore")[:max_bytes] + except (FileNotFoundError, OSError): + continue + return "" + + +_manager: HarnessFilesManager | None = None + + +def init_harness_files_manager(*sources: FileSource) -> None: + global _manager + if _manager is not None: + if _manager.sources == sources: + return + raise RuntimeError( + "HarnessFilesManager already initialized with different sources" + ) + _manager = HarnessFilesManager(sources=sources) + + +def get_harness_files_manager() -> HarnessFilesManager: + if _manager is None: + raise RuntimeError( + "HarnessFilesManager not initialized — call init_harness_files_manager() first" + ) + return _manager + + +def reset_harness_files_manager() -> None: + """Reset the singleton. Only intended for use in tests.""" + global _manager + _manager = None diff --git a/vibe/core/config/harness_files/_paths.py b/vibe/core/config/harness_files/_paths.py new file mode 100644 index 0000000..b3e06e5 --- /dev/null +++ b/vibe/core/config/harness_files/_paths.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from vibe.core.paths import VIBE_HOME, GlobalPath + +GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools") +GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills") +GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents") +GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts") diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index 4c1bd81..5714b98 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Sequence import json import os -import re import types from typing import TYPE_CHECKING, NamedTuple, cast @@ -24,6 +23,7 @@ from vibe.core.types import ( StrToolChoice, ToolCall, ) +from vibe.core.utils import get_server_url_from_api_base if TYPE_CHECKING: from vibe.core.config import ModelConfig, ProviderConfig @@ -170,14 +170,13 @@ class MistralBackend: ) # Mistral SDK takes server URL without api version as input - url_pattern = r"(https?://[^/]+)(/v.*)" - match = re.match(url_pattern, self._provider.api_base) - if not match: + server_url = get_server_url_from_api_base(self._provider.api_base) + if not server_url: raise ValueError( f"Invalid API base URL: {self._provider.api_base}. " "Expected format: /v" ) - self._server_url = match.group(1) + self._server_url = server_url self._timeout = timeout self._retry_config = self._build_retry_config() diff --git a/vibe/core/logger.py b/vibe/core/logger.py index f2b8bb2..3da1eaf 100644 --- a/vibe/core/logger.py +++ b/vibe/core/logger.py @@ -5,7 +5,7 @@ import logging from logging.handlers import RotatingFileHandler import os -from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE +from vibe.core.paths import LOG_DIR, LOG_FILE LOG_DIR.path.mkdir(parents=True, exist_ok=True) diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index e69de29..4bc1593 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from vibe.core.paths._local_config_walk import walk_local_config_dirs_all +from vibe.core.paths._vibe_home import ( + DEFAULT_TOOL_DIR, + GLOBAL_ENV_FILE, + HISTORY_FILE, + LOG_DIR, + LOG_FILE, + PLANS_DIR, + SESSION_LOG_DIR, + TRUSTED_FOLDERS_FILE, + VIBE_HOME, + GlobalPath, +) +from vibe.core.paths.conventions import AGENTS_MD_FILENAMES + +__all__ = [ + "AGENTS_MD_FILENAMES", + "DEFAULT_TOOL_DIR", + "GLOBAL_ENV_FILE", + "HISTORY_FILE", + "LOG_DIR", + "LOG_FILE", + "PLANS_DIR", + "SESSION_LOG_DIR", + "TRUSTED_FOLDERS_FILE", + "VIBE_HOME", + "GlobalPath", + "walk_local_config_dirs_all", +] diff --git a/vibe/core/paths/local_config_walk.py b/vibe/core/paths/_local_config_walk.py similarity index 100% rename from vibe/core/paths/local_config_walk.py rename to vibe/core/paths/_local_config_walk.py diff --git a/vibe/core/paths/global_paths.py b/vibe/core/paths/_vibe_home.py similarity index 75% rename from vibe/core/paths/global_paths.py rename to vibe/core/paths/_vibe_home.py index c7bb509..6118736 100644 --- a/vibe/core/paths/global_paths.py +++ b/vibe/core/paths/_vibe_home.py @@ -26,16 +26,12 @@ def _get_vibe_home() -> Path: VIBE_HOME = GlobalPath(_get_vibe_home) -GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml") GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env") -GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools") -GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills") -GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents") -GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts") SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session") TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml") LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log") +HISTORY_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibehistory") PLANS_DIR = GlobalPath(lambda: VIBE_HOME.path / "plans") DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins") diff --git a/vibe/core/paths/config_paths.py b/vibe/core/paths/config_paths.py deleted file mode 100644 index ce01521..0000000 --- a/vibe/core/paths/config_paths.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Literal - -from vibe.core.paths.global_paths import VIBE_HOME, GlobalPath -from vibe.core.paths.local_config_walk import walk_local_config_dirs_all -from vibe.core.trusted_folders import trusted_folders_manager - -_config_paths_locked: bool = True - - -class ConfigPath(GlobalPath): - @property - def path(self) -> Path: - if _config_paths_locked: - raise RuntimeError("Config path is locked") - return super().path - - -def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path: - cwd = Path.cwd() - is_folder_trusted = trusted_folders_manager.is_trusted(cwd) - if not is_folder_trusted: - return VIBE_HOME.path / basename - if type == "file": - if (candidate := cwd / ".vibe" / basename).is_file(): - return candidate - elif type == "dir": - if (candidate := cwd / ".vibe" / basename).is_dir(): - return candidate - return VIBE_HOME.path / basename - - -def _discover_local_config_dirs_all( - root: Path, -) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: - if not trusted_folders_manager.is_trusted(root): - return ((), (), ()) - return walk_local_config_dirs_all(root) - - -def discover_local_tools_dirs(root: Path) -> list[Path]: - return list(_discover_local_config_dirs_all(root)[0]) - - -def discover_local_skills_dirs(root: Path) -> list[Path]: - return list(_discover_local_config_dirs_all(root)[1]) - - -def discover_local_agents_dirs(root: Path) -> list[Path]: - return list(_discover_local_config_dirs_all(root)[2]) - - -def unlock_config_paths() -> None: - global _config_paths_locked - _config_paths_locked = False - - -CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file")) -CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent) -PROMPTS_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir")) -HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file")) diff --git a/vibe/core/paths/conventions.py b/vibe/core/paths/conventions.py new file mode 100644 index 0000000..9c34598 --- /dev/null +++ b/vibe/core/paths/conventions.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] diff --git a/vibe/core/plan_session.py b/vibe/core/plan_session.py index d839c88..107bfda 100644 --- a/vibe/core/plan_session.py +++ b/vibe/core/plan_session.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path import time -from vibe.core.paths.global_paths import PLANS_DIR +from vibe.core.paths import PLANS_DIR from vibe.core.slug import create_slug diff --git a/vibe/core/proxy_setup.py b/vibe/core/proxy_setup.py index 3411437..7804b9d 100644 --- a/vibe/core/proxy_setup.py +++ b/vibe/core/proxy_setup.py @@ -2,7 +2,7 @@ from __future__ import annotations from dotenv import dotenv_values, set_key, unset_key -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE SUPPORTED_PROXY_VARS: dict[str, str] = { "HTTP_PROXY": "Proxy URL for HTTP requests", diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index b6d6d07..de53f62 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -4,9 +4,8 @@ from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING +from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger -from vibe.core.paths.config_paths import discover_local_skills_dirs -from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR from vibe.core.skills.models import SkillInfo, SkillMetadata from vibe.core.skills.parser import SkillParseError, parse_frontmatter from vibe.core.utils import name_matches @@ -56,10 +55,9 @@ class SkillManager: if path.is_dir(): paths.append(path) - paths.extend(discover_local_skills_dirs(Path.cwd())) - - if GLOBAL_SKILLS_DIR.path.is_dir(): - paths.append(GLOBAL_SKILLS_DIR.path) + mgr = get_harness_files_manager() + paths.extend(mgr.project_skills_dirs) + paths.extend(mgr.user_skills_dirs) unique: list[Path] = [] for p in paths: diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index ccf7da5..e0e5324 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -7,8 +7,8 @@ import subprocess import sys from typing import TYPE_CHECKING +from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.prompts import UtilityPrompt -from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager from vibe.core.utils import is_dangerous_directory, is_windows if TYPE_CHECKING: @@ -20,18 +20,6 @@ if TYPE_CHECKING: _git_status_cache: dict[Path, str] = {} -def _load_project_doc(workdir: Path, max_bytes: int) -> str: - if not trusted_folders_manager.is_trusted(workdir): - return "" - for name in AGENTS_MD_FILENAMES: - path = workdir / name - try: - return path.read_text("utf-8", errors="ignore")[:max_bytes] - except (FileNotFoundError, OSError): - continue - return "" - - class ProjectContextProvider: def __init__( self, config: ProjectContextConfig, root_path: str | Path = "." @@ -297,8 +285,8 @@ def get_universal_system_prompt( sections.append(context) - project_doc = _load_project_doc( - Path.cwd(), config.project_context.max_doc_bytes + project_doc = get_harness_files_manager().load_project_doc( + config.project_context.max_doc_bytes ) if project_doc.strip(): sections.append(project_doc) diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py index c02c90b..3b01912 100644 --- a/vibe/core/tools/builtins/websearch.py +++ b/vibe/core/tools/builtins/websearch.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, final import mistralai from pydantic import BaseModel, Field +from vibe.core.config import Backend from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -17,6 +18,7 @@ from vibe.core.tools.base import ( ) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.types import ToolStreamEvent +from vibe.core.utils import get_server_url_from_api_base if TYPE_CHECKING: from vibe.core.types import ToolCallEvent, ToolResultEvent @@ -66,7 +68,9 @@ class WebSearch( raise ToolError("MISTRAL_API_KEY environment variable not set.") client = mistralai.Mistral( - api_key=api_key, timeout_ms=self.config.timeout * 1000 + api_key=api_key, + server_url=self._resolve_server_url(ctx), + timeout_ms=self.config.timeout * 1000, ) try: @@ -84,6 +88,14 @@ class WebSearch( except mistralai.SDKError as exc: raise ToolError(f"Mistral API error: {exc}") from exc + def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None: + if not ctx or not ctx.agent_manager: + return None + for provider in ctx.agent_manager.config.providers: + if provider.backend == Backend.MISTRAL: + return get_server_url_from_api_base(provider.api_base) + return None + def _parse_response( self, response: mistralai.ConversationResponse ) -> WebSearchResult: diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index cfdea36..9351796 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -9,9 +9,9 @@ import re import sys from typing import TYPE_CHECKING, Any +from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger -from vibe.core.paths.config_paths import discover_local_tools_dirs -from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR +from vibe.core.paths import DEFAULT_TOOL_DIR from vibe.core.tools.base import BaseTool, BaseToolConfig from vibe.core.tools.mcp import MCPRegistry from vibe.core.utils import name_matches @@ -89,8 +89,10 @@ class ToolManager: paths: list[Path] = [DEFAULT_TOOL_DIR.path] paths.extend(config.tool_paths) - paths.extend(discover_local_tools_dirs(Path.cwd())) - paths.append(GLOBAL_TOOLS_DIR.path) + + mgr = get_harness_files_manager() + paths.extend(mgr.project_tools_dirs) + paths.extend(mgr.user_tools_dirs) unique: list[Path] = [] seen: set[Path] = set() diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index fa0b314..06fac19 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -5,10 +5,11 @@ import tomllib import tomli_w -from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE -from vibe.core.paths.local_config_walk import walk_local_config_dirs_all - -AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] +from vibe.core.paths import ( + AGENTS_MD_FILENAMES, + TRUSTED_FOLDERS_FILE, + walk_local_config_dirs_all, +) def has_agents_md_file(path: Path) -> bool: diff --git a/vibe/core/utils.py b/vibe/core/utils.py index 66e243d..a6abb28 100644 --- a/vibe/core/utils.py +++ b/vibe/core/utils.py @@ -337,5 +337,10 @@ def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> ) +def get_server_url_from_api_base(api_base: str) -> str | None: + match = re.match(r"(https?://[^/]+)(/v.*)", api_base) + return match.group(1) if match else None + + def utc_now() -> datetime: return datetime.now(UTC) diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index befb224..bdf0314 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -5,7 +5,7 @@ import sys from rich import print as rprint from textual.app import App -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.setup.onboarding.screens import ApiKeyScreen, WelcomeScreen diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index c5d35e4..98003ab 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -14,7 +14,7 @@ from textual.widgets import Input, Link, Static from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import Backend, VibeConfig -from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.telemetry.send import TelemetryClient from vibe.setup.onboarding.base import OnboardingScreen diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index 86f3012..90ff844 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -11,7 +11,7 @@ from textual.message import Message from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE +from vibe.core.paths import TRUSTED_FOLDERS_FILE class TrustDialogQuitException(Exception): diff --git a/vibe/whats_new.md b/vibe/whats_new.md index cb45ff7..6d3fd33 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,3 @@ -# What's new in v2.4.0 -- **Plan mode**: Plan can be reviewed before switching to accept mode to apply edits. -- **Pricing plan**: Your current plan appears in the CLI banner -- **Per-model auto-compact**: Auto-compact threshold is configurable per model +# What's new in v2.4.1 +- **Prompt mode**: Disabled interactive questions in prompt mode for smoother non-interactive usage +- **VS Code terminal fix**: Space key now works correctly in all input widgets (question prompts, proxy setup)