diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6449832..edce982 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.17.1] - 2026-06-19
+
+### Changed
+
+- Commands in `/help` are now listed alphabetically in both the CLI and ACP
+- `/teleport` is now always available and shows an explicit error when its prerequisites aren't met, instead of being hidden
+
+
## [2.17.0] - 2026-06-19
### Added
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index 9082bd7..4b4e81a 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.17.0"
+version = "2.17.1"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-aarch64-2.17.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-aarch64-2.17.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-x86_64-2.17.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-darwin-x86_64-2.17.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-aarch64-2.17.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-aarch64-2.17.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-x86_64-2.17.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-linux-x86_64-2.17.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-windows-x86_64-2.17.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.1/vibe-acp-windows-x86_64-2.17.1.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index c6a2088..52213d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.17.0"
+version = "2.17.1"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -50,7 +50,7 @@ dependencies = [
"httpx==0.28.1",
"httpx-sse==0.4.3",
"humanize==4.15.0",
- "idna==3.13",
+ "idna==3.18",
"importlib-metadata==8.7.1",
"jaraco-classes==3.4.0",
"jaraco-context==6.1.2",
@@ -65,10 +65,10 @@ dependencies = [
"linkify-it-py==2.1.0",
"markdown-it-py==4.0.0",
"markdownify==1.2.2",
- "mcp==1.27.2",
+ "mcp==1.28.0",
"mdit-py-plugins==0.5.0",
"mdurl==0.1.2",
- "mistralai==2.4.9",
+ "mistralai==2.4.11",
"more-itertools==11.0.2",
"opentelemetry-api==1.39.1",
"opentelemetry-exporter-otlp-proto-common==1.39.1",
diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py
index 136a564..962a922 100644
--- a/tests/acp/test_commands.py
+++ b/tests/acp/test_commands.py
@@ -165,6 +165,25 @@ class TestHandleHelp:
for cmd in main_commands:
assert f"/{cmd}" in content
+ @pytest.mark.asyncio
+ async def test_lists_registered_commands_alphabetically(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_id = await _new_session_and_clear(acp_agent_loop)
+ await _prompt(acp_agent_loop, session_id, "/help")
+
+ content = _get_message_texts(acp_agent_loop)[0]
+ commands_section = content.split("### Available Commands\n\n", maxsplit=1)[
+ 1
+ ].split("\n\n", maxsplit=1)[0]
+ command_names = [
+ line.split("`", maxsplit=2)[1].removeprefix("/")
+ for line in commands_section.splitlines()
+ if line.startswith("- ")
+ ]
+
+ assert command_names == sorted(command_names)
+
@pytest.mark.asyncio
async def test_includes_user_invocable_skills(
self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path
@@ -267,6 +286,28 @@ class TestHandleTeleport:
]
assert _get_tool_updates(acp_agent_loop) == []
+ @pytest.mark.asyncio
+ async def test_teleport_replies_with_error_when_model_not_mistral(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_id = await _new_session_and_clear(acp_agent_loop)
+
+ with patch(
+ "vibe.core.config._settings.VibeConfig.is_active_model_mistral",
+ return_value=False,
+ ):
+ response = await _prompt(acp_agent_loop, session_id, "/teleport")
+
+ assert response.stop_reason == "end_turn"
+ assert response.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {"status": "unavailable"},
+ }
+ texts = _get_message_texts(acp_agent_loop)
+ assert len(texts) == 1
+ assert "active Mistral model" in texts[0]
+ assert _get_tool_updates(acp_agent_loop) == []
+
@pytest.mark.asyncio
async def test_teleport_sends_tool_updates_and_structured_url(
self, acp_agent_loop: VibeAcpAgentLoop
@@ -542,6 +583,25 @@ class TestCommandFallthrough:
class TestAvailableCommandsWithSkills:
+ @pytest.mark.asyncio
+ async def test_available_commands_are_alphabetical(
+ self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path
+ ) -> None:
+ create_skill(skills_dir, "alpha-skill", "First skill")
+
+ await acp_agent_loop_with_skills.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ await _wait_for_available_commands(acp_agent_loop_with_skills)
+
+ updates = _get_client(acp_agent_loop_with_skills)._session_updates
+ available = [
+ u for u in updates if isinstance(u.update, AvailableCommandsUpdate)
+ ]
+ cmd_names = [c.name for c in available[0].update.available_commands]
+
+ assert cmd_names == sorted(cmd_names)
+
@pytest.mark.asyncio
async def test_skills_appear_in_available_commands(
self, acp_agent_loop_with_skills: VibeAcpAgentLoop, skills_dir: Path
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index 2aa9c97..bc3dbbb 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -72,7 +72,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
)
assert response.auth_methods is not None
@@ -172,7 +172,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.1"
)
assert response.auth_methods is not None
diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py
index a0d6726..9127266 100644
--- a/tests/acp/test_load_session.py
+++ b/tests/acp/test_load_session.py
@@ -152,7 +152,7 @@ class TestLoadSession:
"cwd": str(tmp_working_directory.resolve()),
"repoRoot": None,
"ignoredFiles": ["AGENTS.md"],
- "availableDecisions": ["trust_cwd", "trust_session", "decline"],
+ "availableDecisions": ["trust_cwd", "decline"],
},
}
}
diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py
index bfd7c36..a73c621 100644
--- a/tests/acp/test_new_session.py
+++ b/tests/acp/test_new_session.py
@@ -200,7 +200,7 @@ class TestACPNewSession:
"cwd": str(tmp_working_directory.resolve()),
"repoRoot": None,
"ignoredFiles": ["AGENTS.md"],
- "availableDecisions": ["trust_cwd", "trust_session", "decline"],
+ "availableDecisions": ["trust_cwd", "decline"],
},
}
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
@@ -230,12 +230,7 @@ class TestACPNewSession:
"cwd": str(cwd.resolve()),
"repoRoot": str(repo.resolve()),
"ignoredFiles": ["AGENTS.md"],
- "availableDecisions": [
- "trust_repo",
- "trust_cwd",
- "trust_session",
- "decline",
- ],
+ "availableDecisions": ["trust_repo", "trust_cwd", "decline"],
},
}
assert trusted_folders_manager.is_trusted(repo) is None
@@ -264,7 +259,7 @@ class TestACPNewSession:
"cwd": str(tmp_working_directory.resolve()),
"repoRoot": None,
"ignoredFiles": ["AGENTS.md"],
- "availableDecisions": ["trust_cwd", "trust_session", "decline"],
+ "availableDecisions": ["trust_cwd", "decline"],
},
}
assert trusted_folders_manager.is_trusted(tmp_working_directory) is False
diff --git a/tests/acp/test_workspace_trust.py b/tests/acp/test_workspace_trust.py
index 28b17f3..af5f56e 100644
--- a/tests/acp/test_workspace_trust.py
+++ b/tests/acp/test_workspace_trust.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
from pathlib import Path
import pytest
@@ -20,6 +21,14 @@ async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> s
return session.agent_loop.messages[0].content or ""
+async def _wait_for_background_tasks(
+ acp_agent_loop: VibeAcpAgentLoop, session_id: str
+) -> None:
+ session = acp_agent_loop.sessions[session_id]
+ while session._tasks:
+ await asyncio.gather(*list(session._tasks))
+
+
@pytest.fixture
def acp_agent_loop(
backend: FakeBackend, monkeypatch: pytest.MonkeyPatch
@@ -66,12 +75,12 @@ class TestWorkspaceTrustExtMethods:
"cwd": str(tmp_working_directory.resolve()),
"repoRoot": None,
"ignoredFiles": ["AGENTS.md"],
- "availableDecisions": ["trust_cwd", "trust_session", "decline"],
+ "availableDecisions": ["trust_cwd", "decline"],
},
}
@pytest.mark.asyncio
- async def test_workspace_trust_decision_trusts_session_and_reloads_session(
+ async def test_workspace_trust_decision_trusts_cwd_and_reloads_session(
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
) -> None:
(tmp_working_directory / "AGENTS.md").write_text(
@@ -90,19 +99,74 @@ class TestWorkspaceTrustExtMethods:
"trust/decision",
{
"cwd": str(tmp_working_directory),
- "decision": "trust_session",
+ "decision": "trust_cwd",
"session_id": session_response.session_id,
},
)
- assert response == {"trust_status": "session", "details": None}
+ assert response == {"trust_status": "trusted", "details": None}
normalized = str(tmp_working_directory.resolve())
- assert normalized in trusted_folders_manager._session_trusted
- assert normalized not in trusted_folders_manager._trusted
+ assert normalized not in trusted_folders_manager._session_trusted
+ assert normalized in trusted_folders_manager._trusted
+ await _wait_for_background_tasks(acp_agent_loop, session_response.session_id)
assert "Reloaded session instructions" in await _system_prompt(
acp_agent_loop, session_response.session_id
)
+ @pytest.mark.asyncio
+ async def test_workspace_trust_decision_returns_before_reload_completes(
+ self,
+ acp_agent_loop: VibeAcpAgentLoop,
+ tmp_working_directory: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ (tmp_working_directory / "AGENTS.md").write_text(
+ "Slow reload instructions", encoding="utf-8"
+ )
+
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(tmp_working_directory), mcp_servers=[]
+ )
+ assert session_response.session_id is not None
+
+ reload_started = asyncio.Event()
+ release_reload = asyncio.Event()
+
+ async def slow_reload_session_config(session) -> None:
+ reload_started.set()
+ await release_reload.wait()
+
+ monkeypatch.setattr(
+ acp_agent_loop, "_reload_session_config", slow_reload_session_config
+ )
+
+ decision_task = asyncio.create_task(
+ acp_agent_loop.ext_method(
+ "trust/decision",
+ {
+ "cwd": str(tmp_working_directory),
+ "decision": "trust_cwd",
+ "session_id": session_response.session_id,
+ },
+ )
+ )
+
+ try:
+ await asyncio.wait_for(reload_started.wait(), timeout=1)
+ await asyncio.sleep(0)
+
+ assert decision_task.done()
+ assert decision_task.result() == {
+ "trust_status": "trusted",
+ "details": None,
+ }
+ finally:
+ release_reload.set()
+ await decision_task
+ await _wait_for_background_tasks(
+ acp_agent_loop, session_response.session_id
+ )
+
@pytest.mark.asyncio
async def test_workspace_trust_decision_rejects_unavailable_decision(
self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path
@@ -117,6 +181,12 @@ class TestWorkspaceTrustExtMethods:
{"cwd": str(tmp_working_directory), "decision": "trust_repo"},
)
+ with pytest.raises(InvalidRequestError):
+ await acp_agent_loop.ext_method(
+ "trust/decision",
+ {"cwd": str(tmp_working_directory), "decision": "trust_session"},
+ )
+
assert trusted_folders_manager.is_trusted(tmp_working_directory) is None
@pytest.mark.asyncio
@@ -132,7 +202,7 @@ class TestWorkspaceTrustExtMethods:
"trust/decision",
{
"cwd": str(tmp_working_directory),
- "decision": "trust_session",
+ "decision": "trust_cwd",
"session_id": "missing-session",
},
)
diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py
index e3e86b8..47953a6 100644
--- a/tests/cli/plan_offer/test_decide_plan_offer.py
+++ b/tests/cli/plan_offer/test_decide_plan_offer.py
@@ -10,6 +10,7 @@ from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
WhoAmIPlanType,
+ check_teleport_eligibility,
decide_plan_offer,
plan_offer_cta,
plan_title,
@@ -250,6 +251,62 @@ def test_plan_offer_cta_uses_configured_vibe_url() -> None:
)
+def test_check_teleport_eligibility_returns_none_for_eligible_key() -> None:
+ plan_info = PlanInfo(
+ plan_type=WhoAmIPlanType.CHAT,
+ plan_name="INDIVIDUAL",
+ prompt_switching_to_pro_plan=False,
+ )
+
+ assert check_teleport_eligibility(plan_info) is None
+
+
+@pytest.mark.parametrize(
+ "plan_info",
+ [
+ PlanInfo(
+ plan_type=WhoAmIPlanType.CHAT,
+ plan_name="INDIVIDUAL",
+ prompt_switching_to_pro_plan=True,
+ ),
+ PlanInfo(
+ plan_type=WhoAmIPlanType.API,
+ plan_name="FREE",
+ prompt_switching_to_pro_plan=False,
+ ),
+ PlanInfo(
+ plan_type=WhoAmIPlanType.CHAT,
+ plan_name="FREE",
+ prompt_switching_to_pro_plan=False,
+ ),
+ None,
+ ],
+ ids=["pro-plan-wrong-key", "api-free", "chat-free", "unresolved"],
+)
+def test_check_teleport_eligibility_points_ineligible_keys_to_api_key_url(
+ plan_info: PlanInfo | None,
+) -> None:
+ message = check_teleport_eligibility(plan_info)
+
+ assert message is not None
+ assert "https://chat.mistral.ai/code/extensions?focus=key" in message
+
+
+def test_check_teleport_eligibility_uses_configured_vibe_url() -> None:
+ plan_info = PlanInfo(
+ plan_type=WhoAmIPlanType.CHAT,
+ plan_name="INDIVIDUAL",
+ prompt_switching_to_pro_plan=True,
+ )
+
+ message = check_teleport_eligibility(
+ plan_info, vibe_base_url="https://vibe.example.com/"
+ )
+
+ assert message is not None
+ assert "https://vibe.example.com/code/extensions?focus=key" in message
+
+
@pytest.mark.parametrize(
("response", "expected"),
[
diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py
index ac90326..c1f0a0f 100644
--- a/tests/cli/test_commands.py
+++ b/tests/cli/test_commands.py
@@ -1,20 +1,6 @@
from __future__ import annotations
-from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry
-from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
-from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
-
-
-def _eligible_teleport_context() -> CommandAvailabilityContext:
- return CommandAvailabilityContext(
- vibe_code_enabled=True,
- is_active_model_mistral=True,
- plan_info=PlanInfo(
- plan_type=WhoAmIPlanType.CHAT,
- plan_name="INDIVIDUAL",
- prompt_switching_to_pro_plan=False,
- ),
- )
+from vibe.cli.commands import Command, CommandRegistry
class TestCommandRegistry:
@@ -79,7 +65,7 @@ class TestCommandRegistry:
assert registry.parse_command("/teleport") is None
def test_teleport_command_registration_uses_resolved_context(self) -> None:
- registry = CommandRegistry(availability_context=_eligible_teleport_context())
+ registry = CommandRegistry(vibe_code_enabled=True)
assert registry.get_command_name("/teleport") == "teleport"
assert registry.has_command("teleport")
@@ -87,12 +73,23 @@ class TestCommandRegistry:
registry = CommandRegistry()
assert "/teleport" not in registry.get_help_text()
- eligible_registry = CommandRegistry(
- availability_context=_eligible_teleport_context()
- )
+ eligible_registry = CommandRegistry(vibe_code_enabled=True)
assert eligible_registry.get("teleport") is not None
assert "/teleport" in eligible_registry.get_help_text()
+ def test_help_text_lists_commands_alphabetically(self) -> None:
+ registry = CommandRegistry()
+ commands_section = registry.get_help_text().split(
+ "### Commands\n\n", maxsplit=1
+ )[1]
+ command_names = [
+ line.split("`", maxsplit=2)[1].removeprefix("/")
+ for line in commands_section.splitlines()
+ if line.startswith("- ")
+ ]
+
+ assert command_names == sorted(command_names)
+
def test_resume_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/resume") == "resume"
diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py
index 13fcac5..b8fe67e 100644
--- a/tests/cli/test_ui_teleport_availability.py
+++ b/tests/cli/test_ui_teleport_availability.py
@@ -11,6 +11,7 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config
from tests.constants import OPENAI_BASE_URL
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
+from vibe.cli.textual_ui.widgets.messages import ErrorMessage
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend
@@ -48,6 +49,10 @@ def _teleport_failed_events(
]
+def _error_messages(app) -> list[str]:
+ return [error._error for error in app.query(ErrorMessage)]
+
+
@pytest.mark.asyncio
async def test_teleport_command_visible_for_paid_chat_users() -> None:
app = build_test_vibe_app(
@@ -102,66 +107,101 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry(
@pytest.mark.asyncio
-async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
+async def test_teleport_command_visible_but_errors_when_key_not_eligible(
+ telemetry_events: list[dict[str, Any]],
+) -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
- await pilot.pause(0.2)
+ await _wait_until(
+ pilot.pause,
+ lambda: app.commands.get_command_name("/teleport") == "teleport",
+ )
- assert app.commands.get_command_name("/teleport") is None
- assert "/teleport" not in app.commands.get_help_text()
+ assert "/teleport" in app.commands.get_help_text()
input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None
- assert "&" not in input_widget.mode_characters
+ assert "&" in input_widget.mode_characters
+
+ await app.on_chat_input_container_submitted(
+ ChatInputContainer.Submitted("/teleport")
+ )
+ await _wait_until(
+ pilot.pause,
+ lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
+ )
+
+ assert _teleport_failed_events(telemetry_events) == [
+ {
+ "event_name": "vibe.teleport_failed",
+ "properties": {
+ "stage": "ineligible",
+ "error_class": "TeleportIneligibleError",
+ "push_required": False,
+ "nb_session_messages": 0,
+ "session_id": app.agent_loop.session_id,
+ },
+ }
+ ]
@pytest.mark.asyncio
-async def test_hidden_teleport_command_falls_through_as_user_text() -> None:
+async def test_teleport_command_errors_instead_of_user_text_when_not_eligible() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
- await pilot.pause(0.2)
+ await _wait_until(
+ pilot.pause,
+ lambda: app.commands.get_command_name("/teleport") == "teleport",
+ )
- app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
+ await _wait_until(
+ pilot.pause,
+ lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
+ )
- app._handle_teleport_command.assert_not_awaited()
- app._handle_user_message.assert_awaited_once_with("/teleport")
+ app._handle_user_message.assert_not_awaited()
@pytest.mark.asyncio
-async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None:
+async def test_ampersand_teleport_shortcut_errors_when_not_eligible() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
- await pilot.pause(0.2)
+ await _wait_until(
+ pilot.pause,
+ lambda: app.commands.get_command_name("/teleport") == "teleport",
+ )
- app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("&continue")
)
+ await _wait_until(
+ pilot.pause,
+ lambda: any("Vibe Pro API key" in error for error in _error_messages(app)),
+ )
- app._handle_teleport_command.assert_not_awaited()
- app._handle_user_message.assert_awaited_once_with("&continue")
+ app._handle_user_message.assert_not_awaited()
@pytest.mark.asyncio
-async def test_teleport_command_hides_after_switching_to_non_mistral_model(
+async def test_teleport_command_errors_after_switching_to_non_mistral_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key")
@@ -223,11 +263,18 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model(
):
await app._reload_config()
- await _wait_until(
- pilot.pause, lambda: app.commands.get_command_name("/teleport") is None
- )
-
- assert app.commands.get_command_name("/teleport") is None
+ await _wait_until(pilot.pause, lambda: not app.config.is_active_model_mistral())
+ assert app.commands.get_command_name("/teleport") == "teleport"
input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None
- assert "&" not in input_widget.mode_characters
+ assert "&" in input_widget.mode_characters
+
+ await app.on_chat_input_container_submitted(
+ ChatInputContainer.Submitted("/teleport")
+ )
+ await _wait_until(
+ pilot.pause,
+ lambda: any(
+ "active Mistral model" in error for error in _error_messages(app)
+ ),
+ )
diff --git a/tests/cli/textual_ui/test_diff_rendering.py b/tests/cli/textual_ui/test_diff_rendering.py
index fb5405a..527b6a5 100644
--- a/tests/cli/textual_ui/test_diff_rendering.py
+++ b/tests/cli/textual_ui/test_diff_rendering.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from textual.content import Content
from textual.highlight import HighlightTheme
-from textual.widgets import Static
+from textual.widget import Widget
from vibe.cli.textual_ui.widgets.diff_rendering import (
_build_diff_line,
@@ -35,7 +35,9 @@ def _render_with_colors(*args, **kwargs):
return widgets, diff_border_colors(widgets)
-def _plain(widget: Static) -> str:
+def _plain(widget: Widget) -> str:
+ if plain := getattr(widget, "plain", None):
+ return plain
visual = widget.render()
return visual.plain if isinstance(visual, Content) else str(visual)
diff --git a/tests/skills/registry/test_client.py b/tests/skills/registry/test_client.py
new file mode 100644
index 0000000..1a1bf6e
--- /dev/null
+++ b/tests/skills/registry/test_client.py
@@ -0,0 +1,195 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+import respx
+
+from vibe.core.skills.registry._client import RegistrySkillsClient, RegistrySkillsError
+
+_URL = "https://api.mistral.ai/v1/skills"
+
+
+def _page(skill_id: str, *, next_token: str = "") -> dict[str, object]:
+ return {
+ "data": [
+ {"skillId": skill_id, "skill": {"skillName": skill_id, "skillBody": "b"}}
+ ],
+ "nextPageToken": next_token,
+ }
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_catalog_paginates() -> None:
+ route = respx.get(_URL)
+ route.side_effect = [
+ httpx.Response(200, json=_page("a", next_token="p2")),
+ httpx.Response(200, json=_page("b")),
+ ]
+
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ items = await client.list_catalog(page_size=50)
+
+ assert [item.skill_id for item in items] == ["a", "b"]
+ assert route.calls[0].request.url.params["pageSize"] == "50"
+ assert "pageToken" not in route.calls[0].request.url.params
+ assert route.calls[1].request.url.params["pageToken"] == "p2"
+ assert route.calls[0].request.headers["Authorization"] == "Bearer key"
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_catalog_single_page() -> None:
+ respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("only")))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ items = await client.list_catalog(page_size=100)
+ assert [item.skill_id for item in items] == ["only"]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_unauthorized_raises() -> None:
+ respx.get(_URL).mock(return_value=httpx.Response(401, json={"message": "no"}))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="unauthorized"):
+ await client.list_catalog(page_size=10)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_server_error_raises() -> None:
+ respx.get(_URL).mock(return_value=httpx.Response(503))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="unexpected status"):
+ await client.list_catalog(page_size=10)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_non_json_raises() -> None:
+ respx.get(_URL).mock(return_value=httpx.Response(200, text="not json"))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="valid JSON"):
+ await client.list_catalog(page_size=10)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_network_error_raises() -> None:
+ respx.get(_URL).mock(side_effect=httpx.ConnectError("boom"))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="request failed"):
+ await client.list_catalog(page_size=10)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_catalog_sends_fields_mask() -> None:
+ route = respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("a")))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ await client.list_catalog(page_size=10)
+ params = route.calls[0].request.url.params
+ assert params["pageSize"] == "10"
+ assert "skillBody" not in params["fields"]
+ assert "skillName" in params["fields"]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_versions_parses_aliases_and_sorts_desc() -> None:
+ respx.get(f"{_URL}/sid/versions").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "items": [
+ {"version": 1, "versionAttributes": {"aliases": ["old"]}},
+ {
+ "version": 3,
+ "versionAttributes": {"aliases": ["stable", "main"]},
+ },
+ {"version": 2},
+ ]
+ },
+ )
+ )
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ versions = await client.list_versions("sid")
+
+ assert [v.version for v in versions] == [3, 2, 1]
+ assert versions[0].aliases == ["stable", "main"]
+ assert versions[1].aliases == []
+ assert versions[2].aliases == ["old"]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_versions_invalid_payload_raises() -> None:
+ respx.get(f"{_URL}/sid/versions").mock(
+ return_value=httpx.Response(200, json={"items": [{"noVersion": 1}]})
+ )
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="invalid versions response"):
+ await client.list_versions("sid")
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_get_skill_by_id() -> None:
+ respx.get(f"{_URL}/sid").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "skillId": "sid",
+ "skill": {"skillName": "n", "skillBody": "b"},
+ "version": 3,
+ },
+ )
+ )
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ item = await client.get_skill("sid")
+ assert item.skill_id == "sid"
+ assert item.version == 3
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_get_skill_not_found_raises() -> None:
+ respx.get(f"{_URL}/missing").mock(return_value=httpx.Response(404))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="not found"):
+ await client.get_skill("missing")
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_get_skill_invalid_payload_raises() -> None:
+ # A 200 whose body isn't a valid skill object must surface as a registry
+ # error, not a bare pydantic ValidationError.
+ respx.get(f"{_URL}/sid").mock(
+ return_value=httpx.Response(200, json="not-an-object")
+ )
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="invalid skill response"):
+ await client.get_skill("sid")
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_catalog_invalid_payload_raises() -> None:
+ respx.get(_URL).mock(return_value=httpx.Response(200, json="not-an-object"))
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="invalid catalog response"):
+ await client.list_catalog(page_size=10)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_list_catalog_raises_when_page_cap_exceeded() -> None:
+ # Every page reports more pages -> the cap is hit with data remaining, which
+ # must raise rather than silently return a truncated catalog.
+ respx.get(_URL).mock(
+ return_value=httpx.Response(200, json=_page("a", next_token="more"))
+ )
+ async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
+ with pytest.raises(RegistrySkillsError, match="maximum number of pages"):
+ await client.list_catalog(page_size=10)
diff --git a/tests/skills/registry/test_models.py b/tests/skills/registry/test_models.py
new file mode 100644
index 0000000..9268758
--- /dev/null
+++ b/tests/skills/registry/test_models.py
@@ -0,0 +1,169 @@
+from __future__ import annotations
+
+import base64
+import re
+
+import pytest
+
+from vibe.core.skills.registry.models import (
+ ListSkillsResponse,
+ RegistryAssetContent,
+ RegistrySkillItem,
+ sanitize_skill_name,
+)
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("my_skill", "my-skill"),
+ ("My Skill", "my-skill"),
+ (" weird__name--ok ", "weird-name-ok"),
+ ("grill-me", "grill-me"),
+ ("___", None),
+ ("", None),
+ ],
+)
+def test_sanitize_skill_name(raw: str, expected: str | None) -> None:
+ assert sanitize_skill_name(raw) == expected
+
+
+def test_asset_to_bytes_text() -> None:
+ asset = RegistryAssetContent(text_content="hello")
+ assert asset.to_bytes() == b"hello"
+
+
+def test_asset_to_bytes_raw_base64() -> None:
+ encoded = base64.b64encode(b"\x00\x01binary").decode("ascii")
+ asset = RegistryAssetContent(raw_content=encoded, is_executable=True)
+ assert asset.to_bytes() == b"\x00\x01binary"
+ assert asset.is_executable is True
+
+
+def test_asset_to_bytes_invalid_base64_returns_none() -> None:
+ assert RegistryAssetContent(raw_content="not!base64!").to_bytes() is None
+
+
+def test_asset_to_bytes_empty_returns_none() -> None:
+ assert RegistryAssetContent().to_bytes() is None
+
+
+def test_item_parses_camel_case() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skillId": "abc",
+ "skill": {
+ "skillName": "Free Form",
+ "skillDescription": "does things",
+ "skillBody": "# body",
+ "skillAssets": {"ref.txt": {"textContent": "x", "isExecutable": False}},
+ },
+ "metadata": {"name": "my_skill", "latestVersion": 3},
+ "version": 3,
+ })
+ assert item.skill_id == "abc"
+ assert item.resolved_name == "my-skill"
+ assert item.resolved_description == "does things"
+ assert item.skill.skill_assets["ref.txt"].text_content == "x"
+ assert item.version == 3
+
+
+def test_item_parses_snake_case_fallback() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skill_id": "abc",
+ "skill": {"skill_name": "n", "skill_body": "b"},
+ "metadata": {"name": "snake_name"},
+ })
+ assert item.resolved_name == "snake-name"
+
+
+def test_item_name_falls_back_to_skill_name() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skill": {"skillName": "Fallback Name", "skillBody": "b"}
+ })
+ assert item.resolved_name == "fallback-name"
+
+
+def test_item_name_falls_back_to_title() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skillId": "abc",
+ "skill": {"skillBody": "b"},
+ "attributes": {"title": "My Cool Skill"},
+ })
+ assert item.resolved_name == "my-cool-skill"
+
+
+def test_item_name_falls_back_to_skill_id() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skillId": "019de91a-84f5-76af-84a2-5f4389f372c7",
+ "skill": {"skillBody": "b"},
+ })
+ assert item.resolved_name == "skill-019de91a84f576af84a25f4389f372c7"
+
+
+def test_item_name_none_without_any_identifier() -> None:
+ item = RegistrySkillItem.model_validate({"skill": {"skillBody": "b"}})
+ assert item.resolved_name is None
+
+
+def test_item_description_falls_back_to_attributes() -> None:
+ item = RegistrySkillItem.model_validate({
+ "skill": {"skillName": "n", "skillBody": "b"},
+ "attributes": {"title": "T", "description": "attr desc"},
+ })
+ assert item.resolved_description == "attr desc"
+
+
+def test_list_response_parses_pagination() -> None:
+ response = ListSkillsResponse.model_validate({
+ "data": [{"skillId": "1", "skill": {"skillName": "a", "skillBody": "b"}}],
+ "nextPageToken": "next",
+ })
+ assert len(response.data) == 1
+ assert response.next_page_token == "next"
+
+
+def test_list_response_defaults_empty() -> None:
+ response = ListSkillsResponse.model_validate({})
+ assert response.data == []
+ assert response.next_page_token == ""
+
+
+# Resolved/sanitized names must always satisfy SkillMetadata.name's pattern.
+_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
+
+
+def test_sanitize_skill_name_caps_at_64_chars() -> None:
+ name = sanitize_skill_name("x" * 200)
+ assert name is not None
+ assert len(name) <= 64
+ assert _NAME_RE.match(name)
+
+
+def test_resolved_name_id_fallback_is_lowercase_and_valid() -> None:
+ # No usable name anywhere -> falls back to the skill id, which may be
+ # uppercase; it must still be a valid, lowercase skill name.
+ item = RegistrySkillItem.model_validate({
+ "skillId": "AB12CD34-EF56-7890-ABCD-EF1234567890"
+ })
+ assert item.resolved_name == "skill-ab12cd34ef567890abcdef1234567890"
+ assert _NAME_RE.match(item.resolved_name or "")
+
+
+def test_resolved_name_id_fallback_uses_full_id_to_avoid_collisions() -> None:
+ # Ids sharing a prefix (common for time-ordered UUIDv7) must not collapse
+ # to the same fallback name.
+ a = RegistrySkillItem.model_validate({
+ "skillId": "ab12cd34-0000-0000-0000-000000000001"
+ })
+ b = RegistrySkillItem.model_validate({
+ "skillId": "ab12cd34-0000-0000-0000-000000000002"
+ })
+ assert a.resolved_name != b.resolved_name
+
+
+def test_resolved_name_id_fallback_handles_degenerate_id() -> None:
+ # A hyphen-only id must collapse to a valid name, not a trailing-hyphen
+ # "skill-" that would fail validation.
+ item = RegistrySkillItem.model_validate({"skillId": "----"})
+ assert item.resolved_name == "skill"
+ assert _NAME_RE.match(item.resolved_name or "")
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg
index 26f6f5b..3099ad9 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg
@@ -212,31 +212,31 @@
⎢
⎢Commands
⎢
-⎢• /help: Show help message
-⎢• /config: Edit config settings
-⎢• /model: Select active model
-⎢• /thinking: Select thinking level
-⎢• /reload: Reload configuration, agent instructions, and skills from disk
-⎢• /clear: Clear conversation history
-⎢• /copy: Copy the last agent message to the clipboard
-⎢• /log: Show path to current interaction log file
-⎢• /debug: Toggle debug console
-⎢• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary
-⎢• /exit, :q, :quit, exit, quit: Exit the application
-⎢• /status: Display agent statistics
-⎢• /teleport: Teleport session to Vibe Code Web
-⎢• /proxy-setup: Configure proxy and SSL certificate settings
-⎢• /continue, /resume: Browse, resume, or delete saved sessions
-⎢• /rename: Rename the current session
-⎢• /connectors, /mcp: Display available MCP servers and connectors. Pass a name to list tools; subcommands:
-⎢status, login , logout
-⎢• /voice: Configure voice settings
-⎢• /leanstall: Install the Lean 4 agent (leanstral)
-⎢• /unleanstall: Uninstall the Lean 4 agent
-⎢• /rewind: Rewind to a previous message
-⎢• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>, /loop list, or /loop cancel <id|all>
-⎢• /data-retention: Show data retention information
-⎣• /theme: Select theme
+⎢• /clear: Clear conversation history
+⎢• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary
+⎢• /config: Edit config settings
+⎢• /copy: Copy the last agent message to the clipboard
+⎢• /data-retention: Show data retention information
+⎢• /debug: Toggle debug console
+⎢• /exit, :q, :quit, exit, quit: Exit the application
+⎢• /help: Show help message
+⎢• /leanstall: Install the Lean 4 agent (leanstral)
+⎢• /log: Show path to current interaction log file
+⎢• /loop: Schedule a recurring prompt. Use /loop <interval> <prompt>, /loop list, or /loop cancel <id|all>
+⎢• /mcp, /connectors: Display available MCP servers and connectors. Pass a name to list tools; subcommands:
+⎢status, login , logout
+⎢• /model: Select active model
+⎢• /proxy-setup: Configure proxy and SSL certificate settings
+⎢• /reload: Reload configuration, agent instructions, and skills from disk
+⎢• /rename: Rename the current session
+⎢• /resume, /continue: Browse, resume, or delete saved sessions
+⎢• /rewind: Rewind to a previous message
+⎢• /status: Display agent statistics
+⎢• /teleport: Teleport session to Vibe Code Web
+⎢• /theme: Select theme
+⎢• /thinking: Select thinking level
+⎢• /unleanstall: Uninstall the Lean 4 agent
+⎣• /voice: Configure voice settings
────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─
diff --git a/tests/snapshots/test_ui_snapshot_teleport.py b/tests/snapshots/test_ui_snapshot_teleport.py
index 0bfb90f..e45f44e 100644
--- a/tests/snapshots/test_ui_snapshot_teleport.py
+++ b/tests/snapshots/test_ui_snapshot_teleport.py
@@ -149,19 +149,6 @@ class TeleportCommandHelpProApp(TeleportCommandHelpSnapshotApp):
)
-class TeleportCommandHelpFreeApp(TeleportCommandHelpSnapshotApp):
- def __init__(self):
- super().__init__(
- FakeWhoAmIGateway(
- WhoAmIResponse(
- plan_type=WhoAmIPlanType.API,
- plan_name="FREE",
- prompt_switching_to_pro_plan=False,
- )
- )
- )
-
-
def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
@@ -302,16 +289,3 @@ def test_snapshot_teleport_command_visible_for_pro_account(
terminal_size=(120, 48),
run_before=run_before,
)
-
-
-def test_snapshot_teleport_command_hidden_for_non_pro_account(
- snap_compare: SnapCompare,
-) -> None:
- async def run_before(pilot: Pilot) -> None:
- await pilot.pause(0.2)
-
- assert snap_compare(
- "test_ui_snapshot_teleport.py:TeleportCommandHelpFreeApp",
- terminal_size=(120, 48),
- run_before=run_before,
- )
diff --git a/uv.lock b/uv.lock
index a1aeba9..150c5ec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,12 @@
version = 1
revision = 3
requires-python = ">=3.12"
+resolution-markers = [
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version < '3.14' and sys_platform == 'win32'",
+ "python_full_version < '3.14' and sys_platform != 'win32'",
+]
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
@@ -504,11 +510,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.13"
+version = "3.18"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
@@ -677,7 +683,7 @@ name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "altgraph" },
+ { name = "altgraph", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
@@ -779,7 +785,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.27.2"
+version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -797,9 +803,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" },
]
[[package]]
@@ -825,7 +831,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.17.0"
+version = "2.17.1"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -980,7 +986,7 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
{ name = "humanize", specifier = "==4.15.0" },
- { name = "idna", specifier = "==3.13" },
+ { name = "idna", specifier = "==3.18" },
{ name = "importlib-metadata", specifier = "==8.7.1" },
{ name = "jaraco-classes", specifier = "==3.4.0" },
{ name = "jaraco-context", specifier = "==6.1.2" },
@@ -995,10 +1001,10 @@ requires-dist = [
{ name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "markdownify", specifier = "==1.2.2" },
- { name = "mcp", specifier = "==1.27.2" },
+ { name = "mcp", specifier = "==1.28.0" },
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
{ name = "mdurl", specifier = "==0.1.2" },
- { name = "mistralai", specifier = "==2.4.9" },
+ { name = "mistralai", specifier = "==2.4.11" },
{ name = "more-itertools", specifier = "==11.0.2" },
{ name = "opentelemetry-api", specifier = "==1.39.1" },
{ name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" },
@@ -1086,7 +1092,7 @@ dev = [
[[package]]
name = "mistralai"
-version = "2.4.9"
+version = "2.4.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
@@ -1098,9 +1104,9 @@ dependencies = [
{ name = "python-dateutil" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/f22fa0fac767279df95e0796f37520eae0678a75b62d7bca3e85c46b684b/mistralai-2.4.9.tar.gz", hash = "sha256:9a6465b8bddf825d76cf80ab54d55bb8089fdf5aafc0a7943ae74c825df97939", size = 471000, upload-time = "2026-06-03T13:04:22.565Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/de/afe647785ba603d6473dd407edfda917c31e182522381930b43bb6aa25ef/mistralai-2.4.11.tar.gz", hash = "sha256:bc2bb5cae59ce4b07928f5c824eeb5c9a33f6a2a12049725be14619969036f82", size = 488149, upload-time = "2026-06-16T15:41:18.87Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/48/bc/977b65cddce185dce0414d14c783040a9f9d647bbfe9b0f5c74ff9262816/mistralai-2.4.9-py3-none-any.whl", hash = "sha256:8e2f8a58697455f2113b4f6d0d1bd28d66d3eb51f94ff24b9d22a21dea531520", size = 1121336, upload-time = "2026-06-03T13:04:20.694Z" },
+ { url = "https://files.pythonhosted.org/packages/21/6d/0529d895ca381cfa636857259c1fec91d6d7c985b77bf72b9cf5a626d431/mistralai-2.4.11-py3-none-any.whl", hash = "sha256:479f8007ef522e674fbf617e65f8dcdf5d702fc1468c470ae40b1ee0aa0f8136", size = 1170945, upload-time = "2026-06-16T15:41:17.11Z" },
]
[[package]]
@@ -1969,8 +1975,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "jeepney" },
+ { name = "cryptography", marker = "sys_platform != 'win32'" },
+ { name = "jeepney", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
diff --git a/vibe/__init__.py b/vibe/__init__.py
index af8f90d..fedf2cc 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.17.0"
+__version__ = "2.17.1"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index ae98164..e46e8bd 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -73,7 +73,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
-from vibe.acp.commands import AcpCommandAvailabilityContext, AcpCommandRegistry
+from vibe.acp.commands import AcpCommandRegistry
from vibe.acp.exceptions import (
CompactionError,
ConfigurationError,
@@ -224,7 +224,6 @@ WORKSPACE_TRUST_META_KEY = "workspace_trust"
TRUST_GRANT_DECISIONS = {
WorkspaceTrustDecision.TRUST_REPO,
WorkspaceTrustDecision.TRUST_CWD,
- WorkspaceTrustDecision.TRUST_SESSION,
}
@@ -711,9 +710,7 @@ class VibeAcpAgentLoop(AcpAgent):
self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop:
command_registry = AcpCommandRegistry(
- availability_context=AcpCommandAvailabilityContext(
- vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
- )
+ vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
)
session = AcpSessionLoop(
id=session_id, agent_loop=agent_loop, command_registry=command_registry
@@ -824,7 +821,7 @@ class VibeAcpAgentLoop(AcpAgent):
repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None,
ignoredFiles=self._workspace_trust_ignored_files(prompt),
availableDecisions=available_workspace_trust_decisions(
- prompt, include_session=True
+ prompt, include_session=False
),
)
@@ -873,7 +870,7 @@ class VibeAcpAgentLoop(AcpAgent):
raise InvalidRequestError("No workspace trust decision is available.")
available_decisions = available_workspace_trust_decisions(
- prompt, include_session=True
+ prompt, include_session=False
)
if decision not in available_decisions:
raise InvalidRequestError(f"Unsupported trust decision: {decision}")
@@ -914,9 +911,7 @@ class VibeAcpAgentLoop(AcpAgent):
raise InvalidRequestError(str(exc)) from exc
if session is not None and request.decision in TRUST_GRANT_DECISIONS:
- os.chdir(cwd)
- await self._reload_session_config(session)
- await session.command_registry.notify_changed()
+ session.spawn(self._reload_trusted_workspace_session(session, cwd))
return self._workspace_trust_response(cwd).model_dump(
mode="json", by_alias=True
@@ -1201,6 +1196,7 @@ class VibeAcpAgentLoop(AcpAgent):
)
)
+ commands.sort(key=lambda command: command.name)
await self.client.session_update(
session_id=session.id, update=update_available_commands(commands)
)
@@ -1277,10 +1273,7 @@ class VibeAcpAgentLoop(AcpAgent):
return True
async def _reload_config(self, session: AcpSessionLoop) -> None:
- new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths)
- self._apply_client_project_name(new_config)
- _merge_non_interactive_disabled_tools(new_config)
- await session.agent_loop.reload_with_initial_messages(base_config=new_config)
+ await self._reload_session_config(session)
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
model_aliases = [model.alias for model in session.agent_loop.config.models]
@@ -2037,20 +2030,25 @@ class VibeAcpAgentLoop(AcpAgent):
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
lines = ["### Available Commands", ""]
- for cmd in session.command_registry.commands.values():
+ for cmd in sorted(
+ session.command_registry.commands.values(), key=lambda command: command.name
+ ):
hint = f" `<{cmd.input_hint}>`" if cmd.input_hint else ""
lines.append(f"- `/{cmd.name}`{hint}: {cmd.description}")
builtin_names = set(session.command_registry.commands)
- invocable = {
- n: s
- for n, s in session.agent_loop.skill_manager.available_skills.items()
- if s.user_invocable and n not in builtin_names
- }
+ invocable = sorted(
+ (
+ skill
+ for skill in session.agent_loop.skill_manager.available_skills.values()
+ if skill.user_invocable and skill.name not in builtin_names
+ ),
+ key=lambda skill: skill.name,
+ )
if invocable:
lines.extend(["", "### Available Skills", ""])
- for name, info in invocable.items():
- lines.append(f"- `/{name}`: {info.description}")
+ for skill in invocable:
+ lines.append(f"- `/{skill.name}`: {skill.description}")
return await self._command_reply(session, "\n".join(lines), message_id)
@@ -2186,6 +2184,23 @@ class VibeAcpAgentLoop(AcpAgent):
_merge_non_interactive_disabled_tools(new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
+ async def _reload_trusted_workspace_session(
+ self, session: AcpSessionLoop, cwd: Path
+ ) -> None:
+ try:
+ os.chdir(cwd)
+ await self._reload_session_config(session)
+ await session.command_registry.notify_changed()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ logger.warning(
+ "Failed to reload trusted workspace session config: session_id=%s cwd=%s",
+ session.id,
+ cwd,
+ exc_info=exc,
+ )
+
async def _handle_reload(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
diff --git a/vibe/acp/commands/__init__.py b/vibe/acp/commands/__init__.py
index d28826d..5d08b7a 100644
--- a/vibe/acp/commands/__init__.py
+++ b/vibe/acp/commands/__init__.py
@@ -1,5 +1,5 @@
from __future__ import annotations
-from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry
+from vibe.acp.commands.registry import AcpCommandRegistry
-__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"]
+__all__ = ["AcpCommandRegistry"]
diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py
index ad403f4..5970659 100644
--- a/vibe/acp/commands/registry.py
+++ b/vibe/acp/commands/registry.py
@@ -6,17 +6,7 @@ from dataclasses import dataclass, field
type OnCommandsChanged = Callable[[], Awaitable[None]]
-@dataclass(frozen=True)
-class AcpCommandAvailabilityContext:
- """Context used to decide whether a command should be advertised."""
-
- vibe_code_enabled: bool = False
-
- def is_teleport_available(self) -> bool:
- return self.vibe_code_enabled
-
-
-type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool]
+type CommandAvailability = Callable[[bool], bool]
@dataclass(frozen=True)
@@ -34,9 +24,7 @@ class AcpCommand:
class AcpCommandRegistry:
"""Registry of ACP commands. Notifies listeners when commands change."""
- availability_context: AcpCommandAvailabilityContext = field(
- default_factory=AcpCommandAvailabilityContext
- )
+ vibe_code_enabled: bool = False
_commands: dict[str, AcpCommand] = field(default_factory=dict)
_on_changed: OnCommandsChanged | None = None
@@ -51,7 +39,7 @@ class AcpCommandRegistry:
def _is_available(self, command: AcpCommand) -> bool:
if command.is_available is None:
return True
- return command.is_available(self.availability_context)
+ return command.is_available(self.vibe_code_enabled)
def set_on_changed(self, callback: OnCommandsChanged) -> None:
self._on_changed = callback
@@ -101,7 +89,7 @@ def _build_commands() -> dict[str, AcpCommand]:
name="teleport",
description="Teleport session to Vibe Code Web",
handler="_handle_teleport",
- is_available=AcpCommandAvailabilityContext.is_teleport_available,
+ is_available=lambda vibe_code_enabled: vibe_code_enabled,
),
"proxy-setup": AcpCommand(
name="proxy-setup",
diff --git a/vibe/acp/teleport.py b/vibe/acp/teleport.py
index ddfdb96..e55c372 100644
--- a/vibe/acp/teleport.py
+++ b/vibe/acp/teleport.py
@@ -194,6 +194,22 @@ async def handle_teleport_command(
field_meta=_teleport_field_meta("unavailable"),
)
+ if not session.agent_loop.config.is_active_model_mistral():
+ send_teleport_early_failure_telemetry(
+ session.agent_loop.telemetry_client,
+ stage="ineligible",
+ error_class="TeleportIneligibleError",
+ nb_session_messages=len(session.agent_loop.messages[1:]),
+ )
+ return await _teleport_command_reply(
+ client,
+ session,
+ "Teleport requires an active Mistral model. Switch to a Mistral "
+ "model, then try again.",
+ message_id,
+ field_meta=_teleport_field_meta("unavailable"),
+ )
+
last_user_message = next(
(
msg
diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py
index a3cfeb5..f777773 100644
--- a/vibe/cli/commands.py
+++ b/vibe/cli/commands.py
@@ -4,27 +4,10 @@ from collections.abc import Callable
from dataclasses import dataclass
import sys
-from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
-
ALT_KEY = "⌥" if sys.platform == "darwin" else "Alt"
-@dataclass(frozen=True)
-class CommandAvailabilityContext:
- vibe_code_enabled: bool = False
- is_active_model_mistral: bool = False
- plan_info: PlanInfo | None = None
-
- def is_teleport_available(self) -> bool:
- return (
- self.vibe_code_enabled
- and self.is_active_model_mistral
- and self.plan_info is not None
- and self.plan_info.is_teleport_eligible()
- )
-
-
-CommandAvailability = Callable[[CommandAvailabilityContext], bool]
+CommandAvailability = Callable[[bool], bool]
@dataclass
@@ -40,14 +23,13 @@ class CommandRegistry:
def __init__(
self,
excluded_commands: list[str] | None = None,
- availability_context: CommandAvailabilityContext | None = None,
+ vibe_code_enabled: bool = False,
) -> None:
if excluded_commands is None:
excluded_commands = []
self._disabled_commands = set(excluded_commands)
- self._availability_context = CommandAvailabilityContext()
self._commands: dict[str, Command] = {}
- self.refresh(availability_context)
+ self.refresh(vibe_code_enabled)
def _build_commands(self) -> dict[str, Command]:
return {
@@ -116,7 +98,7 @@ class CommandRegistry:
aliases=frozenset(["/teleport"]),
description="Teleport session to Vibe Code Web",
handler="_teleport_command",
- is_available=CommandAvailabilityContext.is_teleport_available,
+ is_available=lambda vibe_code_enabled: vibe_code_enabled,
),
"proxy-setup": Command(
aliases=frozenset(["/proxy-setup"]),
@@ -186,12 +168,8 @@ class CommandRegistry:
def commands(self) -> dict[str, Command]:
return self._commands
- def refresh(
- self, availability_context: CommandAvailabilityContext | None = None
- ) -> None:
- self._availability_context = (
- availability_context or CommandAvailabilityContext()
- )
+ def refresh(self, vibe_code_enabled: bool = False) -> None:
+ self._vibe_code_enabled = vibe_code_enabled
self._commands = {
name: command
for name, command in self._build_commands().items()
@@ -202,7 +180,7 @@ class CommandRegistry:
def _is_command_available(self, command: Command) -> bool:
if command.is_available is None:
return True
- return command.is_available(self._availability_context)
+ return command.is_available(self._vibe_code_enabled)
def _alias_map(self) -> dict[str, str]:
return {
@@ -261,7 +239,13 @@ class CommandRegistry:
"",
]
- for cmd in self.commands.values():
- aliases = ", ".join(f"`{alias}`" for alias in sorted(cmd.aliases))
+ for name, cmd in sorted(self.commands.items()):
+ canonical_alias = f"/{name}"
+ aliases = ", ".join(
+ f"`{alias}`"
+ for alias in sorted(
+ cmd.aliases, key=lambda alias: (alias != canonical_alias, alias)
+ )
+ )
lines.append(f"- {aliases}: {cmd.description}")
return "\n".join(lines)
diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py
index caaed7c..c3b0331 100644
--- a/vibe/cli/plan_offer/decide_plan_offer.py
+++ b/vibe/cli/plan_offer/decide_plan_offer.py
@@ -113,20 +113,41 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
return resolve_api_key(api_env_key)
+def vibe_api_key_url(vibe_base_url: str = DEFAULT_VIBE_BASE_URL) -> str:
+ return f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
+
+
def plan_offer_cta(
payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
) -> str | None:
if not payload:
return
- vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
+ key_url = vibe_api_key_url(vibe_base_url)
if payload.prompt_switching_to_pro_plan:
- return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})"
+ return f"### Switch to your [Vibe Pro API key]({key_url})"
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_chat_plan()
or payload.is_free_mistral_code_plan()
):
- return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})"
+ return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({key_url})"
+
+
+def check_teleport_eligibility(
+ payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
+) -> str | None:
+ if payload is not None and payload.is_teleport_eligible():
+ return None
+ key_url = vibe_api_key_url(vibe_base_url)
+ if payload is not None and payload.prompt_switching_to_pro_plan:
+ return (
+ "Teleport requires a Vibe Pro API key, but the current key is on a "
+ f"different plan. Switch to your Vibe Pro API key: {key_url}"
+ )
+ return (
+ "Teleport requires a Vibe Pro subscription. Your current API key isn't "
+ f"eligible. Upgrade to Vibe Pro: {key_url}"
+ )
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index bbcea3a..2fce0f1 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -31,7 +31,7 @@ from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard
-from vibe.cli.commands import CommandAvailabilityContext, CommandRegistry
+from vibe.cli.commands import CommandRegistry
from vibe.cli.narrator_manager import (
NarratorManager,
NarratorManagerPort,
@@ -40,6 +40,7 @@ from vibe.cli.narrator_manager import (
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
+ check_teleport_eligibility,
decide_plan_offer,
plan_offer_cta,
plan_title,
@@ -184,6 +185,7 @@ from vibe.core.session.saved_sessions import (
from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager
+from vibe.core.telemetry.types import TeleportFailureStage
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import (
TeleportCheckingGitEvent,
@@ -588,20 +590,13 @@ class VibeApp(App): # noqa: PLR0904
def _connectors_enabled(self) -> bool:
return self.agent_loop.connector_registry is not None
- def _get_command_availability_context(self) -> CommandAvailabilityContext:
- return CommandAvailabilityContext(
- vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled,
- is_active_model_mistral=self.config.is_active_model_mistral(),
- plan_info=self._plan_info,
- )
-
def _build_command_registry(self) -> CommandRegistry:
return CommandRegistry(
- availability_context=self._get_command_availability_context()
+ vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled
)
def _refresh_command_registry(self) -> None:
- self.commands.refresh(self._get_command_availability_context())
+ self.commands.refresh(self.agent_loop.base_config.vibe_code_enabled)
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
@@ -1881,29 +1876,54 @@ class VibeApp(App): # noqa: PLR0904
async def _teleport_command(self, **kwargs: Any) -> None:
await self._handle_teleport_command(show_message=False)
+ def _teleport_unavailable_reason(self) -> str | None:
+ if not self.config.is_active_model_mistral():
+ return (
+ "Teleport requires an active Mistral model. Use /model to switch to "
+ "a Mistral model, then try again."
+ )
+ return check_teleport_eligibility(
+ self._plan_info, vibe_base_url=self.config.vibe_base_url
+ )
+
+ async def _fail_teleport_early(
+ self, *, stage: TeleportFailureStage, error_class: str, message: str
+ ) -> None:
+ send_teleport_early_failure_telemetry(
+ self.agent_loop.telemetry_client,
+ stage=stage,
+ error_class=error_class,
+ nb_session_messages=len(self.agent_loop.messages[1:]),
+ )
+ await self._mount_and_scroll(
+ ErrorMessage(message, collapsed=self._tools_collapsed)
+ )
+
async def _handle_teleport_command(
self, value: str | None = None, show_message: bool = True
) -> None:
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
- if not value:
- if show_message:
- await self._mount_and_scroll(SlashCommandMessage("teleport"))
- if not has_history:
- send_teleport_early_failure_telemetry(
- self.agent_loop.telemetry_client,
- stage="no_history",
- error_class="TeleportNoHistoryError",
- nb_session_messages=len(self.agent_loop.messages[1:]),
- )
- await self._mount_and_scroll(
- ErrorMessage(
- "No conversation history to teleport.",
- collapsed=self._tools_collapsed,
- )
- )
- return
- elif show_message:
- await self._mount_and_scroll(TeleportUserMessage(value))
+ if show_message:
+ await self._mount_and_scroll(
+ TeleportUserMessage(value) if value else SlashCommandMessage("teleport")
+ )
+
+ if reason := self._teleport_unavailable_reason():
+ await self._fail_teleport_early(
+ stage="ineligible",
+ error_class="TeleportIneligibleError",
+ message=reason,
+ )
+ return
+
+ if not value and not has_history:
+ await self._fail_teleport_early(
+ stage="no_history",
+ error_class="TeleportNoHistoryError",
+ message="No conversation history to teleport.",
+ )
+ return
+
self.run_worker(self._teleport(value), exclusive=False)
async def _teleport(self, prompt: str | None = None) -> None:
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index 0483e4b..18d3ed0 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -842,9 +842,24 @@ StatusMessage {
color: $warning;
}
-.diff-removed {
+.diff-removed,
+.diff-added,
+.diff-context {
+ width: 100%;
height: auto;
+ .diff-gutter {
+ width: auto;
+ height: auto;
+ }
+
+ .diff-body {
+ width: 1fr;
+ height: auto;
+ }
+}
+
+.diff-removed {
&:dark {
background: $error 10%;
}
@@ -859,8 +874,6 @@ StatusMessage {
}
.diff-added {
- height: auto;
-
&:dark {
background: $success 10%;
}
@@ -883,10 +896,6 @@ StatusMessage {
}
}
-.diff-context {
- height: auto;
-}
-
.todo-empty,
.todo-cancelled {
height: auto;
diff --git a/vibe/cli/textual_ui/widgets/diff_rendering.py b/vibe/cli/textual_ui/widgets/diff_rendering.py
index 2030ff7..7c0801a 100644
--- a/vibe/cli/textual_ui/widgets/diff_rendering.py
+++ b/vibe/cli/textual_ui/widgets/diff_rendering.py
@@ -5,6 +5,8 @@ import difflib
from pathlib import Path
import re
+from textual.app import ComposeResult
+from textual.containers import Horizontal
from textual.content import Content
from textual.highlight import (
ANSIDarkHighlightTheme,
@@ -12,9 +14,13 @@ from textual.highlight import (
HighlightTheme,
highlight as highlight_code,
)
+from textual.widget import Widget
from textual.widgets import Static
-from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
+from vibe.cli.textual_ui.widgets.no_markup_static import (
+ NoMarkupStatic,
+ NonSelectableStatic,
+)
from vibe.core.utils.io import read_safe
from vibe.core.utils.text import snippet_start_lines
@@ -60,6 +66,38 @@ def _highlight_line(code: str, language: str, theme: type[HighlightTheme]) -> Co
return lines[0] if lines else Content(code)
+def _gutter_styles(prefix_char: str, *, ansi: bool) -> tuple[str, str]:
+ if prefix_char == "-":
+ if ansi:
+ return f"bold {_REMOVED_STYLE}", f"bold {_REMOVED_STYLE}"
+ return _REMOVED_STYLE, _DIM_MUTED_STYLE
+ if prefix_char == "+":
+ sign_style = _ADDED_STYLE
+ return sign_style, _ADDED_STYLE if ansi else _DIM_MUTED_STYLE
+ return _MUTED_STYLE, _DIM_MUTED_STYLE
+
+
+def _build_diff_gutter(prefix_char: str, lineno: int | None, *, ansi: bool) -> Content:
+ sign_style, lineno_style = _gutter_styles(prefix_char, ansi=ansi)
+ lineno_str = f"{lineno:>4} " if lineno is not None else ""
+ prefix = f"{prefix_char} "
+ return Content.styled(lineno_str, lineno_style) + Content.styled(prefix, sign_style)
+
+
+def _build_diff_body(
+ code: str,
+ prefix_char: str,
+ language: str,
+ *,
+ ansi: bool,
+ theme: type[HighlightTheme],
+) -> Content:
+ body = _highlight_line(code, language, theme)
+ if prefix_char == "-" and ansi:
+ body = body.stylize("dim")
+ return body
+
+
def _build_diff_line(
code: str,
prefix_char: str,
@@ -69,31 +107,23 @@ def _build_diff_line(
ansi: bool,
theme: type[HighlightTheme],
) -> Content:
- # ANSI themes lack row backgrounds; the gutter carries the diff color instead.
- body = _highlight_line(code, language, theme)
-
- if prefix_char == "-":
- if ansi:
- sign_style = lineno_style = f"bold {_REMOVED_STYLE}"
- body = body.stylize("dim")
- else:
- sign_style, lineno_style = _REMOVED_STYLE, _DIM_MUTED_STYLE
- elif prefix_char == "+":
- sign_style = _ADDED_STYLE
- lineno_style = _ADDED_STYLE if ansi else _DIM_MUTED_STYLE
- else:
- sign_style, lineno_style = _MUTED_STYLE, _DIM_MUTED_STYLE
-
- lineno_str = f"{lineno:>4} " if lineno is not None else ""
- prefix = f"{prefix_char} "
-
- return (
- Content.styled(lineno_str, lineno_style)
- + Content.styled(prefix, sign_style)
- + body
+ return _build_diff_gutter(prefix_char, lineno, ansi=ansi) + _build_diff_body(
+ code, prefix_char, language, ansi=ansi, theme=theme
)
+class _DiffRow(Horizontal):
+ def __init__(self, gutter: Content, body: Content, *, classes: str) -> None:
+ self._gutter = gutter
+ self._body = body
+ self.plain = gutter.plain + body.plain
+ super().__init__(classes=classes)
+
+ def compose(self) -> ComposeResult:
+ yield NonSelectableStatic(self._gutter, classes="diff-gutter")
+ yield Static(self._body, classes="diff-body")
+
+
def render_edit_diff(
old_string: str,
new_string: str,
@@ -102,7 +132,7 @@ def render_edit_diff(
*,
ansi: bool,
dark: bool,
-) -> list[Static]:
+) -> list[Widget]:
theme = _pick_theme(ansi=ansi, dark=dark)
diff_lines = list(
difflib.unified_diff(
@@ -119,7 +149,7 @@ def render_edit_diff(
# replace_all repeats the same change at each match; render one block per
# occurrence, anchored at its own line number, with a gap in between.
- widgets: list[Static] = []
+ widgets: list[Widget] = []
for index, start_line in enumerate(start_lines):
if index > 0:
widgets.append(NoMarkupStatic("⋯", classes="diff-gap"))
@@ -136,10 +166,10 @@ def _render_occurrence(
*,
ansi: bool,
theme: type[HighlightTheme],
-) -> list[Static]:
+) -> list[Widget]:
offset = (start_line - 1) if start_line else 0
old_lineno = new_lineno = 0 # overwritten by the first @@ header
- widgets: list[Static] = []
+ widgets: list[Widget] = []
first_hunk = True
for line in diff_lines:
@@ -167,20 +197,18 @@ def _render_occurrence(
old_lineno += 1
new_lineno += 1
- content = _build_diff_line(
- code,
- prefix_char,
- lineno if start_line else None,
- language,
- ansi=ansi,
- theme=theme,
+ lineno_val = lineno if start_line else None
+ gutter = _build_diff_gutter(prefix_char, lineno_val, ansi=ansi)
+ body = _build_diff_body(code, prefix_char, language, ansi=ansi, theme=theme)
+
+ widgets.append(
+ _DiffRow(gutter, body, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char])
)
- widgets.append(Static(content, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char]))
return widgets
-def diff_border_colors(rows: Iterable[Static]) -> dict[int, str]:
+def diff_border_colors(rows: Iterable[Widget]) -> dict[int, str]:
return {
i: color
for i, row in enumerate(rows)
diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py
index 50581c0..20c9f84 100644
--- a/vibe/cli/textual_ui/widgets/tool_widgets.py
+++ b/vibe/cli/textual_ui/widgets/tool_widgets.py
@@ -245,7 +245,7 @@ class EditResultWidget(ToolResultWidget[EditResult]):
if not self.result:
yield from self._footer()
return
- rows: list[Static] = [
+ rows: list[Widget] = [
NoMarkupStatic(f"⚠ {w}", classes="tool-result-warning")
for w in self.warnings
]
diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py
index 1ef3eb9..26c1a7f 100644
--- a/vibe/core/config/_settings.py
+++ b/vibe/core/config/_settings.py
@@ -785,6 +785,15 @@ class VibeConfig(BaseSettings):
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
+ experimental_enable_registry_skills: bool = Field(
+ default=False,
+ description=(
+ "Experimental: pull workspace skills from the Mistral AI Registry"
+ " (api.mistral.ai) and make them available alongside local skills."
+ " Requires a Mistral provider and API key. Local and builtin skills take"
+ " precedence on name collision."
+ ),
+ )
model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py
index 892083c..c27762c 100644
--- a/vibe/core/config/vibe_schema.py
+++ b/vibe/core/config/vibe_schema.py
@@ -177,6 +177,15 @@ class VibeConfigSchema(ConfigSchema):
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
+ experimental_enable_registry_skills: Annotated[bool, WithReplaceMerge()] = Field(
+ default=False,
+ description=(
+ "Experimental: pull workspace skills from the Mistral AI Registry"
+ " (api.mistral.ai) and make them available alongside local skills."
+ " Requires a Mistral provider and API key. Local and builtin skills take"
+ " precedence on name collision."
+ ),
+ )
# Internal
vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True
diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py
index aa744a1..e48b078 100644
--- a/vibe/core/skills/models.py
+++ b/vibe/core/skills/models.py
@@ -1,11 +1,39 @@
from __future__ import annotations
+from enum import StrEnum, auto
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, field_validator
+class SkillSource(StrEnum):
+ BUILTIN = auto()
+ LOCAL = auto()
+ REGISTRY = auto()
+
+
+class SkillScope(StrEnum):
+ BUILTIN = auto()
+ GLOBAL = auto()
+ PROJECT = auto()
+
+
+# The registry's reserved alias that always resolves to the newest version,
+# server-side (see ai-registry versioning.RESERVED_ALIAS). A pin set to this
+# alias auto-resolves to latest and never reports an "update available".
+REGISTRY_LATEST_ALIAS = "latest"
+
+
+class RegistryRef(BaseModel):
+ skill_id: str
+ # The concrete, materialized version currently on disk (for display/loading).
+ version: int
+ # The pinned alias (e.g. "latest") when this is an alias pin; None when the
+ # pin is frozen to a specific version number.
+ alias: str | None = None
+
+
class SkillMetadata(BaseModel):
model_config = {"populate_by_name": True}
@@ -72,6 +100,9 @@ class SkillInfo(BaseModel):
user_invocable: bool = True
skill_path: Path | None = None
prompt: str
+ source: SkillSource = SkillSource.LOCAL
+ scope: SkillScope = SkillScope.GLOBAL
+ registry: RegistryRef | None = None
model_config = {"arbitrary_types_allowed": True}
@@ -83,7 +114,14 @@ class SkillInfo(BaseModel):
@classmethod
def from_metadata(
- cls, meta: SkillMetadata, skill_path: Path, prompt: str
+ cls,
+ meta: SkillMetadata,
+ skill_path: Path,
+ prompt: str,
+ *,
+ source: SkillSource = SkillSource.LOCAL,
+ scope: SkillScope = SkillScope.GLOBAL,
+ registry: RegistryRef | None = None,
) -> SkillInfo:
return cls(
name=meta.name,
@@ -95,6 +133,9 @@ class SkillInfo(BaseModel):
user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(),
prompt=prompt,
+ source=source,
+ scope=scope,
+ registry=registry,
)
diff --git a/vibe/core/skills/registry/__init__.py b/vibe/core/skills/registry/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vibe/core/skills/registry/_client.py b/vibe/core/skills/registry/_client.py
new file mode 100644
index 0000000..6ba4ece
--- /dev/null
+++ b/vibe/core/skills/registry/_client.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Any
+
+import httpx
+from pydantic import BaseModel, ValidationError
+
+from vibe.core.skills.registry.models import (
+ ListSkillsResponse,
+ ListVersionsResponse,
+ RegistrySkillItem,
+ SkillVersionInfo,
+)
+from vibe.core.utils.http import build_ssl_context
+
+_MAX_PAGES = 50
+
+
+class RegistrySkillsError(Exception):
+ def __init__(self, reason: str) -> None:
+ super().__init__(reason)
+ self.reason = reason
+
+
+def _parse[M: BaseModel](model: type[M], payload: Any, what: str) -> M:
+ """Validate a registry payload, normalizing failures to RegistrySkillsError.
+
+ Keeps every response-parsing path consistent: a malformed 200 surfaces as a
+ registry error, not a bare ValidationError that callers wouldn't catch.
+ """
+ try:
+ return model.model_validate(payload)
+ except ValidationError as exc:
+ raise RegistrySkillsError(f"invalid {what} response") from exc
+
+
+class RegistrySkillsClient:
+ _CATALOG_FIELDS = (
+ "skillId,skill.skillName,skill.skillDescription,attributes,metadata,version"
+ )
+
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0) -> None:
+ self._skills_url = f"{base_url.rstrip('/')}/skills"
+ self._api_key = api_key
+ self._timeout = timeout
+ self._client: httpx.AsyncClient | None = None
+
+ async def __aenter__(self) -> RegistrySkillsClient:
+ self._client = httpx.AsyncClient(
+ timeout=self._timeout,
+ headers={
+ "Authorization": f"Bearer {self._api_key}",
+ "Accept": "application/json",
+ },
+ verify=build_ssl_context(),
+ )
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ async def list_catalog(self, *, page_size: int) -> list[RegistrySkillItem]:
+ return await self._list(page_size=page_size, fields=self._CATALOG_FIELDS)
+
+ async def list_versions(self, skill_id: str) -> list[SkillVersionInfo]:
+ payload = await self._get_json(f"{self._skills_url}/{skill_id}/versions", {})
+ parsed = _parse(ListVersionsResponse, payload, "versions")
+ infos = [row.to_info() for row in parsed.items]
+ infos.sort(key=lambda v: v.version, reverse=True)
+ return infos
+
+ async def get_skill(
+ self, skill_id: str, *, version: int | None = None, alias: str | None = None
+ ) -> RegistrySkillItem:
+ # version_selector is a oneof: version XOR alias (omit both = latest).
+ params: dict[str, Any] = {}
+ if version is not None:
+ params["version"] = version
+ elif alias is not None:
+ params["alias"] = alias
+ url = f"{self._skills_url}/{skill_id}"
+ payload = await self._get_json(url, params)
+ return _parse(RegistrySkillItem, payload, "skill")
+
+ async def _list(
+ self, *, page_size: int, fields: str | None
+ ) -> list[RegistrySkillItem]:
+ items: list[RegistrySkillItem] = []
+ page_token = ""
+ for _ in range(_MAX_PAGES):
+ params: dict[str, Any] = {"pageSize": page_size}
+ if fields:
+ params["fields"] = fields
+ if page_token:
+ params["pageToken"] = page_token
+ page = _parse(
+ ListSkillsResponse,
+ await self._get_json(self._skills_url, params),
+ "catalog",
+ )
+ items.extend(page.data)
+ if not page.next_page_token:
+ return items
+ page_token = page.next_page_token
+ # Cap reached with pages still remaining: fail loudly rather than return
+ # a silently-truncated catalog that a caller would treat as complete.
+ raise RegistrySkillsError("catalog exceeds the maximum number of pages")
+
+ async def _get_json(self, url: str, params: dict[str, Any]) -> Any:
+ if self._client is None:
+ raise RegistrySkillsError("client used outside of an async context")
+ try:
+ response = await self._client.get(url, params=params)
+ except httpx.RequestError as exc:
+ raise RegistrySkillsError(f"request failed: {exc}") from exc
+
+ if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
+ raise RegistrySkillsError(f"unauthorized ({response.status_code})")
+ if response.status_code == httpx.codes.NOT_FOUND:
+ raise RegistrySkillsError("not found (404)")
+ if not response.is_success:
+ raise RegistrySkillsError(f"unexpected status {response.status_code}")
+
+ try:
+ return response.json()
+ except ValueError as exc:
+ raise RegistrySkillsError("response was not valid JSON") from exc
diff --git a/vibe/core/skills/registry/models.py b/vibe/core/skills/registry/models.py
new file mode 100644
index 0000000..3b593bc
--- /dev/null
+++ b/vibe/core/skills/registry/models.py
@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+import base64
+import binascii
+import re
+
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field
+
+_NON_NAME_CHARS = re.compile(r"[^a-z0-9]+")
+# Mirror SkillMetadata.name's max_length so a sanitized name always validates.
+_MAX_NAME_LEN = 64
+
+
+def sanitize_skill_name(raw: str) -> str | None:
+ collapsed = _NON_NAME_CHARS.sub("-", raw.strip().lower()).strip("-")
+ collapsed = collapsed[:_MAX_NAME_LEN].rstrip("-")
+ return collapsed or None
+
+
+class RegistryAssetContent(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ text_content: str | None = Field(
+ default=None, validation_alias=AliasChoices("textContent", "text_content")
+ )
+ raw_content: str | None = Field(
+ default=None, validation_alias=AliasChoices("rawContent", "raw_content")
+ )
+ is_executable: bool = Field(
+ default=False, validation_alias=AliasChoices("isExecutable", "is_executable")
+ )
+
+ def to_bytes(self) -> bytes | None:
+ if self.text_content is not None:
+ return self.text_content.encode("utf-8")
+ if self.raw_content is not None:
+ try:
+ return base64.b64decode(self.raw_content, validate=True)
+ except (binascii.Error, ValueError):
+ return None
+ return None
+
+
+class RegistrySkillPayload(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ skill_name: str = Field(
+ default="", validation_alias=AliasChoices("skillName", "skill_name")
+ )
+ skill_description: str = Field(
+ default="",
+ validation_alias=AliasChoices("skillDescription", "skill_description"),
+ )
+ skill_body: str = Field(
+ default="", validation_alias=AliasChoices("skillBody", "skill_body")
+ )
+ skill_assets: dict[str, RegistryAssetContent] = Field(
+ default_factory=dict,
+ validation_alias=AliasChoices("skillAssets", "skill_assets"),
+ )
+
+
+class RegistryAttributes(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ title: str = ""
+ description: str | None = None
+
+
+class RegistryMetadata(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ name: str = ""
+ latest_version: int = Field(
+ default=0, validation_alias=AliasChoices("latestVersion", "latest_version")
+ )
+ sharing_scope: str = Field(
+ default="", validation_alias=AliasChoices("sharingScope", "sharing_scope")
+ )
+ created_at: str = Field(
+ default="", validation_alias=AliasChoices("createdAt", "created_at")
+ )
+ last_modified_at: str = Field(
+ default="", validation_alias=AliasChoices("lastModifiedAt", "last_modified_at")
+ )
+ created_by: str = Field(
+ default="", validation_alias=AliasChoices("createdBy", "created_by")
+ )
+
+
+class RegistryVersionMetadata(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ created_at: str = Field(
+ default="", validation_alias=AliasChoices("createdAt", "created_at")
+ )
+
+
+class RegistryVersionAttributes(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ notes: str = ""
+ aliases: list[str] = Field(default_factory=list)
+
+
+class RegistrySkillItem(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ skill_id: str = Field(
+ default="", validation_alias=AliasChoices("skillId", "skill_id")
+ )
+ skill: RegistrySkillPayload = Field(default_factory=RegistrySkillPayload)
+ attributes: RegistryAttributes = Field(default_factory=RegistryAttributes)
+ metadata: RegistryMetadata = Field(default_factory=RegistryMetadata)
+ version: int = 0
+ version_metadata: RegistryVersionMetadata = Field(
+ default_factory=RegistryVersionMetadata,
+ validation_alias=AliasChoices("versionMetadata", "version_metadata"),
+ )
+ version_attributes: RegistryVersionAttributes = Field(
+ default_factory=RegistryVersionAttributes,
+ validation_alias=AliasChoices("versionAttributes", "version_attributes"),
+ )
+
+ @property
+ def resolved_name(self) -> str | None:
+ for candidate in (
+ self.metadata.name,
+ self.skill.skill_name,
+ self.attributes.title,
+ ):
+ if (name := sanitize_skill_name(candidate)) is not None:
+ return name
+ if self.skill_id:
+ # Run the id-based fallback through the same sanitizer so it obeys
+ # the charset / length / hyphen rules SkillMetadata.name enforces.
+ # The full id (not a prefix) keeps unnamed skills collision-free.
+ return sanitize_skill_name(f"skill-{self.skill_id.replace('-', '')}")
+ return None
+
+ @property
+ def resolved_description(self) -> str:
+ if self.skill.skill_description.strip():
+ return self.skill.skill_description.strip()
+ if self.attributes.description and self.attributes.description.strip():
+ return self.attributes.description.strip()
+ return ""
+
+
+class ListSkillsResponse(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ data: list[RegistrySkillItem] = Field(default_factory=list)
+ next_page_token: str = Field(
+ default="", validation_alias=AliasChoices("nextPageToken", "next_page_token")
+ )
+
+
+class SkillVersionInfo(BaseModel):
+ """One row from the versions endpoint: a concrete version and its author
+ aliases (e.g. 'main', 'stable'). The reserved 'latest' alias is dynamic and
+ not part of this list.
+ """
+
+ version: int
+ aliases: list[str] = Field(default_factory=list)
+
+
+class RegistryVersionRow(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ version: int
+ version_attributes: RegistryVersionAttributes = Field(
+ default_factory=RegistryVersionAttributes,
+ validation_alias=AliasChoices("versionAttributes", "version_attributes"),
+ )
+
+ def to_info(self) -> SkillVersionInfo:
+ return SkillVersionInfo(
+ version=self.version, aliases=list(self.version_attributes.aliases)
+ )
+
+
+class ListVersionsResponse(BaseModel):
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ items: list[RegistryVersionRow] = Field(default_factory=list)
diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py
index 4a7629f..c6daa47 100644
--- a/vibe/core/telemetry/types.py
+++ b/vibe/core/telemetry/types.py
@@ -60,7 +60,7 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
TeleportFailureStage = Literal[
- "no_history", "git_check", "push", "workflow_start", "cancelled"
+ "no_history", "ineligible", "git_check", "push", "workflow_start", "cancelled"
]