diff --git a/docs/docs.json b/docs/docs.json
index 4b49fae7c..bc5f8c7bf 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -397,8 +397,14 @@
"group": "Build",
"pages": [
"edge/en/enterprise/features/automations",
- "edge/en/enterprise/features/crew-studio",
- "edge/en/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "edge/en/enterprise/features/crew-studio",
+ "edge/en/enterprise/features/merged-step-card"
+ ]
+ },
"edge/en/enterprise/features/marketplace",
"edge/en/enterprise/features/agent-repositories",
"edge/en/enterprise/features/tools-and-integrations",
@@ -922,8 +928,14 @@
"group": "Build",
"pages": [
"v1.14.7/en/enterprise/features/automations",
- "v1.14.7/en/enterprise/features/crew-studio",
- "v1.14.7/en/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "v1.14.7/en/enterprise/features/crew-studio",
+ "v1.14.7/en/enterprise/features/merged-step-card"
+ ]
+ },
"v1.14.7/en/enterprise/features/marketplace",
"v1.14.7/en/enterprise/features/agent-repositories",
"v1.14.7/en/enterprise/features/tools-and-integrations",
@@ -8549,8 +8561,14 @@
"group": "Construir",
"pages": [
"edge/pt-BR/enterprise/features/automations",
- "edge/pt-BR/enterprise/features/crew-studio",
- "edge/pt-BR/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "edge/pt-BR/enterprise/features/crew-studio",
+ "edge/pt-BR/enterprise/features/merged-step-card"
+ ]
+ },
"edge/pt-BR/enterprise/features/marketplace",
"edge/pt-BR/enterprise/features/agent-repositories",
"edge/pt-BR/enterprise/features/tools-and-integrations",
@@ -9051,8 +9069,14 @@
"group": "Construir",
"pages": [
"v1.14.7/pt-BR/enterprise/features/automations",
- "v1.14.7/pt-BR/enterprise/features/crew-studio",
- "v1.14.7/pt-BR/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "v1.14.7/pt-BR/enterprise/features/crew-studio",
+ "v1.14.7/pt-BR/enterprise/features/merged-step-card"
+ ]
+ },
"v1.14.7/pt-BR/enterprise/features/marketplace",
"v1.14.7/pt-BR/enterprise/features/agent-repositories",
"v1.14.7/pt-BR/enterprise/features/tools-and-integrations",
@@ -16415,8 +16439,14 @@
"group": "빌드",
"pages": [
"edge/ko/enterprise/features/automations",
- "edge/ko/enterprise/features/crew-studio",
- "edge/ko/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "edge/ko/enterprise/features/crew-studio",
+ "edge/ko/enterprise/features/merged-step-card"
+ ]
+ },
"edge/ko/enterprise/features/marketplace",
"edge/ko/enterprise/features/agent-repositories",
"edge/ko/enterprise/features/tools-and-integrations",
@@ -16929,8 +16959,14 @@
"group": "빌드",
"pages": [
"v1.14.7/ko/enterprise/features/automations",
- "v1.14.7/ko/enterprise/features/crew-studio",
- "v1.14.7/ko/enterprise/features/merged-step-card",
+ {
+ "group": "Crew Studio",
+ "icon": "pencil",
+ "pages": [
+ "v1.14.7/ko/enterprise/features/crew-studio",
+ "v1.14.7/ko/enterprise/features/merged-step-card"
+ ]
+ },
"v1.14.7/ko/enterprise/features/marketplace",
"v1.14.7/ko/enterprise/features/agent-repositories",
"v1.14.7/ko/enterprise/features/tools-and-integrations",
@@ -24473,8 +24509,14 @@
"group": "البناء",
"pages": [
"edge/ar/enterprise/features/automations",
- "edge/ar/enterprise/features/crew-studio",
- "edge/ar/enterprise/features/merged-step-card",
+ {
+ "group": "استوديو الطاقم",
+ "icon": "pencil",
+ "pages": [
+ "edge/ar/enterprise/features/crew-studio",
+ "edge/ar/enterprise/features/merged-step-card"
+ ]
+ },
"edge/ar/enterprise/features/marketplace",
"edge/ar/enterprise/features/agent-repositories",
"edge/ar/enterprise/features/tools-and-integrations",
@@ -24987,8 +25029,14 @@
"group": "البناء",
"pages": [
"v1.14.7/ar/enterprise/features/automations",
- "v1.14.7/ar/enterprise/features/crew-studio",
- "v1.14.7/ar/enterprise/features/merged-step-card",
+ {
+ "group": "استوديو الطاقم",
+ "icon": "pencil",
+ "pages": [
+ "v1.14.7/ar/enterprise/features/crew-studio",
+ "v1.14.7/ar/enterprise/features/merged-step-card"
+ ]
+ },
"v1.14.7/ar/enterprise/features/marketplace",
"v1.14.7/ar/enterprise/features/agent-repositories",
"v1.14.7/ar/enterprise/features/tools-and-integrations",
diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx
index 11d035da7..ee5c31b14 100644
--- a/docs/edge/ar/changelog.mdx
+++ b/docs/edge/ar/changelog.mdx
@@ -4,6 +4,29 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
+
+ ## v1.14.8a4
+
+ [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## ما الذي تغير
+
+ ### الميزات
+ - دعم تدفقات المحادثة في واجهة سطر الأوامر TUI.
+
+ ### إصلاحات الأخطاء
+ - إصلاح مسار التوجيه الرمزي في استخراج أرشيف المهارات.
+ - التحقق من صحة مسارات تعريف التدفق الإعلاني.
+
+ ### الوثائق
+ - تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a3.
+
+ ## المساهمون
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
## v1.14.8a3
diff --git a/docs/edge/ar/enterprise/features/merged-step-card.mdx b/docs/edge/ar/enterprise/features/merged-step-card.mdx
index 76ab3e98f..776edfe10 100644
--- a/docs/edge/ar/enterprise/features/merged-step-card.mdx
+++ b/docs/edge/ar/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
-
-
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx
index 1b990991d..5c741a965 100644
--- a/docs/edge/en/changelog.mdx
+++ b/docs/edge/en/changelog.mdx
@@ -4,6 +4,29 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
+
+ ## v1.14.8a4
+
+ [View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## What's Changed
+
+ ### Features
+ - Support conversational flows in the CLI TUI.
+
+ ### Bug Fixes
+ - Fix symlink path traversal in skill archive extraction.
+ - Validate declarative flow definition paths.
+
+ ### Documentation
+ - Update snapshot and changelog for v1.14.8a3.
+
+ ## Contributors
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
## v1.14.8a3
diff --git a/docs/edge/en/enterprise/features/merged-step-card.mdx b/docs/edge/en/enterprise/features/merged-step-card.mdx
index 6011d1302..f0a3be647 100644
--- a/docs/edge/en/enterprise/features/merged-step-card.mdx
+++ b/docs/edge/en/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
-
-
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:
diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx
index fe25de51c..091b559bb 100644
--- a/docs/edge/ko/changelog.mdx
+++ b/docs/edge/ko/changelog.mdx
@@ -4,6 +4,29 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
+
+ ## v1.14.8a4
+
+ [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## 변경 사항
+
+ ### 기능
+ - CLI TUI에서 대화형 흐름 지원.
+
+ ### 버그 수정
+ - 기술 아카이브 추출 시 심볼릭 링크 경로 탐색 문제 수정.
+ - 선언적 흐름 정의 경로 검증.
+
+ ### 문서
+ - v1.14.8a3에 대한 스냅샷 및 변경 로그 업데이트.
+
+ ## 기여자
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
## v1.14.8a3
diff --git a/docs/edge/ko/enterprise/features/merged-step-card.mdx b/docs/edge/ko/enterprise/features/merged-step-card.mdx
index 4103353cf..d45301d56 100644
--- a/docs/edge/ko/enterprise/features/merged-step-card.mdx
+++ b/docs/edge/ko/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
-
-
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx
index 5a2f653ce..d03bce6fb 100644
--- a/docs/edge/pt-BR/changelog.mdx
+++ b/docs/edge/pt-BR/changelog.mdx
@@ -4,6 +4,29 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
+
+ ## v1.14.8a4
+
+ [Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## O que Mudou
+
+ ### Recursos
+ - Suporte a fluxos de conversa na TUI do CLI.
+
+ ### Correções de Bugs
+ - Corrigir a travessia de caminho de symlink na extração de arquivo de habilidade.
+ - Validar os caminhos de definição de fluxo declarativo.
+
+ ### Documentação
+ - Atualizar snapshot e changelog para v1.14.8a3.
+
+ ## Contribuidores
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
## v1.14.8a3
diff --git a/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx
index 8b9d937d0..3ca6cee60 100644
--- a/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx
+++ b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
-
-
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:
diff --git a/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx
index 76ab3e98f..776edfe10 100644
--- a/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx
+++ b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
-
-
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
diff --git a/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx
index 6011d1302..f0a3be647 100644
--- a/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx
+++ b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
-
-
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:
diff --git a/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx
index 4103353cf..d45301d56 100644
--- a/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx
+++ b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
-
-
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
diff --git a/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx
index 8b9d937d0..3ca6cee60 100644
--- a/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx
+++ b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx
@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
-{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
-
- **Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
-
-
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:
diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml
index e48f1fd3b..1b04f4f32 100644
--- a/lib/cli/pyproject.toml
+++ b/lib/cli/pyproject.toml
@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
- "crewai-core==1.14.8a3",
+ "crewai-core==1.14.8a4",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",
diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py
index 7b09b91f5..928b412af 100644
--- a/lib/cli/src/crewai_cli/__init__.py
+++ b/lib/cli/src/crewai_cli/__init__.py
@@ -1 +1 @@
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"
diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py
index 9b3930350..81aae6c47 100644
--- a/lib/cli/src/crewai_cli/crew_run_tui.py
+++ b/lib/cli/src/crewai_cli/crew_run_tui.py
@@ -17,7 +17,7 @@ from textual.binding import Binding, BindingType
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.css.query import NoMatches
from textual.screen import ModalScreen
-from textual.widgets import Button, Footer, Header, Static
+from textual.widgets import Button, Footer, Header, Input, Static
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
@@ -382,6 +382,18 @@ Screen {
height: auto;
}
+#conversation-input {
+ display: none;
+ height: 3;
+ border-top: hkey #333333;
+ background: #1c1c1c;
+ color: #e0e0e0;
+}
+
+#conversation-input:focus {
+ border-top: hkey #1F7982;
+}
+
Header {
background: #1c1c1c;
color: #FF5A50;
@@ -483,6 +495,7 @@ FooterKey .footer-key--key {
total_tasks: int = 0,
agent_names: list[str] | None = None,
task_names: list[str] | None = None,
+ conversational: bool = False,
):
super().__init__()
self.title = f"CrewAI — {crew_name}"
@@ -544,6 +557,13 @@ FooterKey .footer-key--key {
self._event_handlers: list[tuple[type, Any]] = []
self._crew: Any = None
+ self._flow: Any = None
+ self._is_conversational = conversational
+ self._conversation_messages: list[tuple[str, str]] = []
+ self._conversation_turns = 0
+ self._conversation_turn_in_progress = False
+ self._conversation_previous_defer_trace_finalization: bool | None = None
+ self._conversation_exit_commands = {"exit", "quit"}
self._default_inputs: dict[str, Any] | None = None
self._crew_result: Any = None
self._crew_json_path: Any = None
@@ -566,6 +586,10 @@ FooterKey .footer-key--key {
yield Static(id="task-header")
with VerticalScroll(id="scroll-area"):
yield Static(id="main-content")
+ yield Input(
+ placeholder="Message the flow...",
+ id="conversation-input",
+ )
with VerticalScroll(id="log-panel"):
yield Static(id="log-content")
yield Footer()
@@ -574,7 +598,9 @@ FooterKey .footer-key--key {
self._start_time = time.time()
self._subscribe()
self._tick_timer = self.set_interval(1 / 8, self._tick)
- if self._crew:
+ if self._is_conversational and self._flow:
+ self._start_conversational_session()
+ elif self._crew:
self._run_crew_worker()
elif self._crew_json_path:
self._load_and_run_worker()
@@ -725,6 +751,140 @@ FooterKey .footer-key--key {
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
+ # ── Conversational flow execution ───────────────────────
+
+ def _start_conversational_session(self) -> None:
+ from crewai.events.listeners.tracing.utils import (
+ set_suppress_tracing_messages,
+ set_tui_mode,
+ )
+
+ set_tui_mode(True)
+ set_suppress_tracing_messages(True)
+ with self._lock:
+ self._status = "chatting"
+ self._current_step = None
+ self._elapsed_frozen = None
+ self._conversation_previous_defer_trace_finalization = getattr(
+ self._flow, "defer_trace_finalization", False
+ )
+ self._flow.defer_trace_finalization = True
+
+ try:
+ input_widget = self.query_one("#conversation-input", Input)
+ input_widget.display = True
+ input_widget.focus()
+ except Exception: # noqa: S110
+ pass
+
+ def _finalize_conversational_session(self) -> None:
+ if not (self._is_conversational and self._flow):
+ return
+ try:
+ self._flow.finalize_session_traces()
+ except Exception: # noqa: S110
+ pass
+ previous = self._conversation_previous_defer_trace_finalization
+ if previous is not None:
+ try:
+ self._flow.defer_trace_finalization = previous
+ except Exception: # noqa: S110
+ pass
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ if event.input.id != "conversation-input":
+ return
+ if not self._is_conversational:
+ return
+
+ message = event.value.strip()
+ event.input.value = ""
+ if not message:
+ return
+ if message.lower() in self._conversation_exit_commands:
+ self._finalize_conversational_session()
+ self._unsubscribe()
+ self.exit(self._crew_result)
+ return
+ if self._conversation_turn_in_progress:
+ return
+
+ with self._lock:
+ self._conversation_messages.append(("user", message))
+ self._conversation_turn_in_progress = True
+ self._conversation_turns += 1
+ self._status = "working"
+ self._current_step = ("yellow", "Thinking…", "")
+ self._is_streaming = False
+ self._streaming_text = ""
+ self._task_full_output = ""
+ self._current_llm_text = ""
+
+ event.input.disabled = True
+ self._run_conversation_turn_worker(message)
+
+ @work(thread=True, exclusive=True, group="conversation")
+ def _run_conversation_turn_worker(self, message: str) -> None:
+ from crewai.events.listeners.tracing.utils import (
+ set_suppress_tracing_messages,
+ set_tui_mode,
+ )
+
+ set_tui_mode(True)
+ set_suppress_tracing_messages(True)
+ try:
+ result = self._flow.handle_turn(message)
+ if hasattr(result, "get_full_text") and hasattr(result, "result"):
+ for _chunk in result:
+ pass
+ result = result.result
+ self.call_from_thread(self._on_conversation_turn_done, result)
+ except Exception as e:
+ self.call_from_thread(self._on_conversation_turn_failed, str(e))
+
+ def _on_conversation_turn_done(self, result: Any) -> None:
+ with self._lock:
+ output = self._stringify_output(result)
+ self._conversation_messages.append(("assistant", output))
+ self._crew_result = result
+ self._conversation_turn_in_progress = False
+ self._status = "chatting"
+ self._is_streaming = False
+ self._streaming_text = ""
+ self._current_step = None
+ self._enable_conversation_input()
+ self._tick()
+ self._scroll_to_result()
+
+ def _on_conversation_turn_failed(self, error: str) -> None:
+ with self._lock:
+ self._status = "failed"
+ self._error = error
+ self._conversation_turn_in_progress = False
+ self._is_streaming = False
+ self._current_step = None
+ self._enable_conversation_input()
+ self._tick()
+
+ def _enable_conversation_input(self) -> None:
+ try:
+ input_widget = self.query_one("#conversation-input", Input)
+ input_widget.disabled = False
+ input_widget.focus()
+ except Exception: # noqa: S110
+ pass
+
+ def _stringify_output(self, result: Any) -> str:
+ raw_result = getattr(result, "raw", result)
+ if raw_result is None:
+ return ""
+ if isinstance(raw_result, str):
+ return raw_result
+ try:
+ return _json.dumps(raw_result, default=str, ensure_ascii=False)
+ except TypeError:
+ return str(raw_result)
+
# ── Actions ─────────────────────────────────────────────
def action_toggle_sidebar(self) -> None:
@@ -783,6 +943,7 @@ FooterKey .footer-key--key {
self._refresh_log_panel()
async def action_quit(self) -> None:
+ self._finalize_conversational_session()
self._unsubscribe()
self.exit(self._crew_result)
@@ -958,6 +1119,30 @@ FooterKey .footer-key--key {
t = Text()
sidebar_width = 30
+ if self._is_conversational:
+ t.append(" CONVERSATION\n", style=f"bold {_C_PRIMARY}")
+ t.append("\n")
+ if self._conversation_turn_in_progress:
+ t.append(f" {self._spinner()} ", style=_C_PRIMARY)
+ t.append("Working\n", style=f"bold {_C_TEXT}")
+ elif self._status == "failed":
+ t.append(" ✘ Failed\n", style=_C_RED)
+ else:
+ t.append(" ● Ready\n", style=_C_GREEN)
+ t.append(f" Turns {self._conversation_turns}\n", style=_C_DIM)
+ t.append("\n")
+ t.append(" TOKENS\n", style=f"bold {_C_PRIMARY}")
+ t.append("\n")
+ out = self._output_tokens + self._live_out_tokens
+ t.append(f" ↑ {self._input_tokens:,}\n", style=_C_DIM)
+ t.append(f" ↓ {out:,}\n", style=_C_DIM)
+ t.append("\n")
+ t.append(" COMMANDS\n", style=f"bold {_C_PRIMARY}")
+ t.append("\n")
+ t.append(" quit / exit\n", style=_C_DIM)
+ widget.update(t)
+ return
+
t.append(" TASKS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
@@ -1011,6 +1196,22 @@ FooterKey .footer-key--key {
widget = self.query_one("#task-header", Static)
t = Text()
+ if self._is_conversational:
+ if self._status == "failed":
+ t.append("✘ ", style=f"bold {_C_RED}")
+ t.append("Failed", style=f"bold {_C_RED}")
+ if self._error:
+ t.append(f"\n{self._error[:120]}", style=_C_RED)
+ elif self._conversation_turn_in_progress:
+ t.append(f"{self._spinner()} ", style=_C_PRIMARY)
+ t.append("Flow is responding", style=f"bold {_C_PRIMARY}")
+ else:
+ t.append("● ", style=f"bold {_C_GREEN}")
+ t.append("Conversational flow ready", style=f"bold {_C_GREEN}")
+ t.append(" Type a message below", style=_C_DIM)
+ widget.update(t)
+ return
+
if self._status == "completed":
elapsed = self._elapsed_frozen or (time.time() - self._start_time)
t.append("✔ ", style=f"bold {_C_GREEN}")
@@ -1062,6 +1263,41 @@ FooterKey .footer-key--key {
t = Text()
should_scroll = False
+ if self._is_conversational:
+ if not self._conversation_messages and not self._is_streaming:
+ t.append(" Start the conversation below.\n", style=_C_MUTED)
+ for role, content in self._conversation_messages:
+ if role == "user":
+ t.append("\n You\n", style=f"bold {_C_TEAL}")
+ else:
+ t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
+ rendered = _format_json_in_text(_unescape_text(content))
+ for line in rendered.split("\n"):
+ style = _C_TEXT if role == "assistant" else _C_DIM
+ t.append(f" {line}\n", style=style)
+
+ if self._is_streaming and self._streaming_text:
+ text = _unescape_text(self._filtered_streaming_text())
+ if text.strip():
+ t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
+ for line in text.rstrip().split("\n")[-40:]:
+ t.append(f" {line}\n", style=_C_TEXT)
+ should_scroll = True
+
+ if self._status == "failed" and self._error:
+ t.append("\n Error\n", style=f"bold {_C_RED}")
+ t.append(f" {self._error}\n", style=_C_RED)
+
+ widget.update(t)
+ if should_scroll:
+ try:
+ self.query_one("#scroll-area", VerticalScroll).scroll_end(
+ animate=False
+ )
+ except Exception: # noqa: S110
+ pass
+ return
+
# Plan section
if self._plan and self._plan.get("steps"):
plan_title = self._plan.get("plan", "Plan")
diff --git a/lib/cli/src/crewai_cli/experimental/skills/main.py b/lib/cli/src/crewai_cli/experimental/skills/main.py
index 81f9b3fc5..612b85d82 100644
--- a/lib/cli/src/crewai_cli/experimental/skills/main.py
+++ b/lib/cli/src/crewai_cli/experimental/skills/main.py
@@ -378,12 +378,40 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
- """Path-traversal-safe extraction for Python < 3.12."""
+ """Path-traversal-safe extraction for Python versions without tar filters.
+
+ Validates both the member's own path and, for symlink/hardlink members,
+ the link target. Without the link-target check a malicious archive can
+ plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``)
+ followed by a regular member written *through* that link
+ (``link/authorized_keys``), escaping ``dest`` even though every member
+ name resolves inside it. This mirrors the protection that
+ ``tarfile.extractall(..., filter="data")`` provides when available.
+ """
dest_resolved = dest.resolve()
for member in tf.getmembers():
member_path = (dest / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
+ if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
+ raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
+ if member.issym() or member.islnk():
+ link_target = member.linkname
+ # Absolute link targets always escape the destination.
+ if os.path.isabs(link_target):
+ raise ValueError(
+ f"Blocked link target escaping destination: "
+ f"{member.name!r} -> {link_target!r}"
+ )
+ # Hardlink names are relative to the archive root; symlink
+ # targets are relative to the member's own directory.
+ anchor = dest if member.islnk() else (dest / member.name).parent
+ resolved_target = (anchor / link_target).resolve()
+ if not resolved_target.is_relative_to(dest_resolved):
+ raise ValueError(
+ f"Blocked link target escaping destination: "
+ f"{member.name!r} -> {link_target!r}"
+ )
tf.extractall(dest) # noqa: S202
diff --git a/lib/cli/src/crewai_cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py
new file mode 100644
index 000000000..bde1ddee7
--- /dev/null
+++ b/lib/cli/src/crewai_cli/kickoff_flow.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import importlib
+import inspect
+from pathlib import Path
+import subprocess
+import sys
+from typing import Any
+
+import click
+
+
+def _project_script_target(script_name: str) -> str | None:
+ try:
+ from crewai_cli.utils import read_toml
+
+ pyproject = read_toml()
+ except Exception:
+ return None
+
+ target = pyproject.get("project", {}).get("scripts", {}).get(script_name)
+ return target if isinstance(target, str) else None
+
+
+def _prepare_project_import_path() -> None:
+ cwd = Path.cwd()
+ for path in (cwd / "src", cwd):
+ path_str = str(path)
+ if path.exists() and path_str not in sys.path:
+ sys.path.insert(0, path_str)
+
+
+def _load_conversational_flow_from_kickoff_script() -> Any | None:
+ target = _project_script_target("kickoff")
+ if not target or ":" not in target:
+ return None
+
+ module_name, _callable_name = target.split(":", 1)
+ _prepare_project_import_path()
+
+ try:
+ module = importlib.import_module(module_name)
+ from crewai.flow.flow import Flow
+ except Exception:
+ return None
+
+ for value in vars(module).values():
+ if (
+ inspect.isclass(value)
+ and value is not Flow
+ and issubclass(value, Flow)
+ and getattr(value, "conversational", False)
+ ):
+ return value()
+
+ for value in vars(module).values():
+ if (
+ isinstance(value, Flow)
+ and getattr(value, "conversational", False)
+ and callable(getattr(value, "handle_turn", None))
+ ):
+ return value
+
+ return None
+
+
+def _run_conversational_flow_tui(flow: Any) -> Any:
+ from crewai_cli.crew_run_tui import CrewRunApp
+
+ app = CrewRunApp(
+ crew_name=getattr(flow, "name", None) or type(flow).__name__,
+ conversational=True,
+ )
+ app._flow = flow
+ app.run()
+
+ if app._status == "failed":
+ raise SystemExit(1)
+
+ return app._crew_result
+
+
+def kickoff_flow() -> None:
+ """
+ Kickoff the flow by running a command in the UV environment.
+ """
+ flow = _load_conversational_flow_from_kickoff_script()
+ if flow is not None:
+ _run_conversational_flow_tui(flow)
+ return
+
+ command = ["uv", "run", "kickoff"]
+
+ try:
+ result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
+
+ if result.stderr:
+ click.echo(result.stderr, err=True)
+
+ except subprocess.CalledProcessError as e:
+ click.echo(f"An error occurred while running the flow: {e}", err=True)
+ click.echo(e.output, err=True)
+
+ except Exception as e:
+ click.echo(f"An unexpected error occurred: {e}", err=True)
diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py
index f9948a297..de6c8c412 100644
--- a/lib/cli/src/crewai_cli/run_crew.py
+++ b/lib/cli/src/crewai_cli/run_crew.py
@@ -604,6 +604,16 @@ def _run_flow_project(
run_declarative_flow_in_project_env(definition=definition)
return
+ from crewai_cli.kickoff_flow import (
+ _load_conversational_flow_from_kickoff_script,
+ _run_conversational_flow_tui,
+ )
+
+ flow = _load_conversational_flow_from_kickoff_script()
+ if flow is not None:
+ _run_conversational_flow_tui(flow)
+ return
+
_execute_uv_script("kickoff", entity_type="flow")
diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py
index b70492777..ea289d00b 100644
--- a/lib/cli/src/crewai_cli/run_declarative_flow.py
+++ b/lib/cli/src/crewai_cli/run_declarative_flow.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
-from pathlib import Path
+from pathlib import Path, PureWindowsPath
import subprocess
from typing import Any
@@ -12,7 +12,7 @@ from crewai_cli.utils import build_env_with_all_tool_credentials
def run_declarative_flow_in_project_env(
- definition: str, inputs: str | None = None
+ definition: str | Path, inputs: str | None = None
) -> None:
"""Run a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
@@ -25,7 +25,7 @@ def run_declarative_flow_in_project_env(
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
-def plot_declarative_flow_in_project_env(definition: str) -> None:
+def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
"""Plot a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
plot_declarative_flow(definition=definition)
@@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None:
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
-def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
+def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
"""Run a declarative flow from a definition path."""
parsed_inputs = _parse_inputs(inputs)
@@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
click.echo(_format_result(result))
-def plot_declarative_flow(definition: str) -> None:
+def plot_declarative_flow(definition: str | Path) -> None:
"""Plot a declarative flow from a definition path."""
try:
flow = load_declarative_flow(definition)
@@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None:
raise SystemExit(1) from exc
-def load_declarative_flow(definition: str) -> Any:
+def load_declarative_flow(definition: str | Path) -> Any:
"""Load a declarative Flow instance from a definition path."""
try:
from crewai.flow.flow import Flow
@@ -102,7 +102,8 @@ def load_declarative_flow(definition: str) -> Any:
def configured_project_declarative_flow(
pyproject_data: dict[str, Any] | None = None,
-) -> str | None:
+ project_root: Path | None = None,
+) -> Path | None:
"""Return the configured declarative flow source for flow projects."""
if pyproject_data is None:
try:
@@ -118,7 +119,66 @@ def configured_project_declarative_flow(
definition = crewai_config.get("definition")
if not isinstance(definition, str):
return None
- return definition.strip() or None
+ definition = definition.strip()
+ if not definition:
+ return None
+
+ return _resolve_project_definition_path(
+ definition=definition,
+ project_root=project_root or Path.cwd(),
+ )
+
+
+def _resolve_project_definition_path(definition: str, project_root: Path) -> Path:
+ definition_path = Path(definition)
+ windows_definition_path = PureWindowsPath(definition)
+
+ if definition.startswith("~"):
+ raise click.UsageError(
+ "[tool.crewai] definition must be a project-local path; "
+ f"got {definition!r}."
+ )
+
+ if definition_path.is_absolute() or windows_definition_path.is_absolute():
+ raise click.UsageError(
+ "[tool.crewai] definition must be relative to the project root; "
+ f"got {definition!r}."
+ )
+
+ try:
+ root = project_root.resolve(strict=True)
+ except OSError as exc:
+ raise click.UsageError(
+ f"Invalid project root for [tool.crewai] definition: {exc}"
+ ) from exc
+
+ candidate = root / definition_path
+ try:
+ resolved_candidate = candidate.resolve(strict=False)
+ except OSError as exc:
+ raise click.UsageError(
+ f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
+ ) from exc
+
+ if not resolved_candidate.is_relative_to(root):
+ raise click.UsageError(
+ "[tool.crewai] definition must resolve inside the project root; "
+ f"got {definition!r}."
+ )
+
+ if not resolved_candidate.exists():
+ raise click.UsageError(
+ "[tool.crewai] definition must point to an existing file; "
+ f"got {definition!r}."
+ )
+
+ if not resolved_candidate.is_file():
+ raise click.UsageError(
+ "[tool.crewai] definition must point to a regular file; "
+ f"got {definition!r}."
+ )
+
+ return resolved_candidate
def _execute_declarative_flow_command(command: list[str]) -> None:
diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
index 44a1c84e2..258c654a2 100644
--- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
- "crewai[tools]==1.14.8a3"
+ "crewai[tools]==1.14.8a4"
]
[project.scripts]
diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml
index c462fc779..a0ac1fb79 100644
--- a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
- "crewai[tools]==1.14.8a3"
+ "crewai[tools]==1.14.8a4"
]
[build-system]
diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
index e0ff1433a..c825051c5 100644
--- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
- "crewai[tools]==1.14.8a3"
+ "crewai[tools]==1.14.8a4"
]
[project.scripts]
diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
index 1b09cdf09..19534dff8 100644
--- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
- "crewai[tools]==1.14.8a3"
+ "crewai[tools]==1.14.8a4"
]
[tool.crewai]
diff --git a/lib/cli/tests/skills/test_safe_extract.py b/lib/cli/tests/skills/test_safe_extract.py
new file mode 100644
index 000000000..f1083f5fa
--- /dev/null
+++ b/lib/cli/tests/skills/test_safe_extract.py
@@ -0,0 +1,140 @@
+"""Regression tests for path-traversal-safe archive extraction.
+
+Guards against symlink/hardlink-based path traversal in the fallback used on
+Python versions without tarfile extraction filters. The filtered path relies on
+`tarfile.extractall(..., filter="data")`; the fallback must provide the same
+protection by validating link targets, not just member names.
+"""
+
+from __future__ import annotations
+
+import io
+import tarfile
+from pathlib import Path
+
+import pytest
+
+from crewai_cli.experimental.skills.main import _safe_extractall
+
+
+def _tar_from_members(build) -> tarfile.TarFile:
+ """Build an in-memory tar archive via `build(tf)` and return it for reading."""
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w") as tf:
+ build(tf)
+ buf.seek(0)
+ return tarfile.open(fileobj=buf, mode="r")
+
+
+def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None:
+ """A symlink whose target escapes dest, plus a file written through it,
+ must be rejected before anything is extracted."""
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ link = tarfile.TarInfo("link")
+ link.type = tarfile.SYMTYPE
+ link.linkname = str(outside) # absolute path outside dest
+ tf.addfile(link)
+ payload = b"pwned"
+ info = tarfile.TarInfo("link/evil.txt")
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="escaping destination"):
+ _safe_extractall(tf, dest)
+
+ assert not (outside / "evil.txt").exists()
+
+
+def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None:
+ """A relative symlink (../..) that escapes dest is also rejected."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ link = tarfile.TarInfo("sub/link")
+ link.type = tarfile.SYMTYPE
+ link.linkname = "../../outside" # escapes dest from sub/
+ tf.addfile(link)
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="escaping destination"):
+ _safe_extractall(tf, dest)
+
+
+def test_blocks_hardlink_escaping_destination(tmp_path: Path) -> None:
+ """A hardlink whose target escapes dest is rejected."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ link = tarfile.TarInfo("escape")
+ link.type = tarfile.LNKTYPE
+ link.linkname = "../outside.txt" # escapes archive root
+ tf.addfile(link)
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="escaping destination"):
+ _safe_extractall(tf, dest)
+
+
+def test_blocks_special_tar_member(tmp_path: Path) -> None:
+ """Special tar members such as FIFOs are rejected."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ fifo = tarfile.TarInfo("pipe")
+ fifo.type = tarfile.FIFOTYPE
+ tf.addfile(fifo)
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="unsupported tar member"):
+ _safe_extractall(tf, dest)
+
+
+def test_allows_benign_relative_symlink(tmp_path: Path) -> None:
+ """A symlink that stays within dest is permitted."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ payload = b"hi"
+ info = tarfile.TarInfo("real.txt")
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+ link = tarfile.TarInfo("alias.txt")
+ link.type = tarfile.SYMTYPE
+ link.linkname = "real.txt" # stays inside dest
+ tf.addfile(link)
+
+ with _tar_from_members(build) as tf:
+ _safe_extractall(tf, dest)
+
+ assert (dest / "real.txt").read_bytes() == b"hi"
+ assert (dest / "alias.txt").is_symlink()
+ assert (dest / "alias.txt").readlink() == Path("real.txt")
+
+
+def test_allows_benign_archive(tmp_path: Path) -> None:
+ """An ordinary archive of regular files extracts correctly."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")):
+ payload = body
+ info = tarfile.TarInfo(name)
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+
+ with _tar_from_members(build) as tf:
+ _safe_extractall(tf, dest)
+
+ assert (dest / "SKILL.md").read_bytes() == b"# skill"
+ assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)"
diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py
index 969bc5ae2..5c49dabf1 100644
--- a/lib/cli/tests/test_crew_run_tui.py
+++ b/lib/cli/tests/test_crew_run_tui.py
@@ -126,6 +126,52 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non
assert "Deploy failed with exit code 42" in capsys.readouterr().out
+def test_conversation_turn_done_records_assistant_message() -> None:
+ class RawResult:
+ raw = "hello from the flow"
+
+ app = CrewRunApp(conversational=True)
+ app._conversation_turn_in_progress = True
+ app._enable_conversation_input = lambda: None # type: ignore[method-assign]
+ app._tick = lambda: None # type: ignore[method-assign]
+ app._scroll_to_result = lambda: None # type: ignore[method-assign]
+
+ app._on_conversation_turn_done(RawResult())
+
+ assert app._conversation_messages == [("assistant", "hello from the flow")]
+ assert app._conversation_turn_in_progress is False
+ assert app._status == "chatting"
+ assert isinstance(app._crew_result, RawResult)
+
+
+@pytest.mark.asyncio
+async def test_conversation_input_submits_turn() -> None:
+ class FakeFlow:
+ defer_trace_finalization = False
+
+ def handle_turn(self, message: str) -> str:
+ return f"reply: {message}"
+
+ def finalize_session_traces(self) -> None:
+ pass
+
+ app = CrewRunApp(crew_name="Demo", conversational=True)
+ app._flow = FakeFlow()
+
+ async with app.run_test() as pilot:
+ await pilot.click("#conversation-input")
+ await pilot.press("h", "i", "enter")
+ for _ in range(50):
+ await pilot.pause(0.05)
+ if app._conversation_messages[-1:] == [("assistant", "reply: hi")]:
+ break
+
+ assert app._conversation_messages == [
+ ("user", "hi"),
+ ("assistant", "reply: hi"),
+ ]
+
+
def test_plan_step_status_updates_only_the_explicit_step() -> None:
app = _app_with_plan()
diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py
index 00e39b6db..5743275e7 100644
--- a/lib/cli/tests/test_flow_commands.py
+++ b/lib/cli/tests/test_flow_commands.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
import subprocess
+import click
import pytest
from click.testing import CliRunner
@@ -107,6 +108,8 @@ def test_configured_project_declarative_flow(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
+ definition_path = tmp_path / "flow.yaml"
+ definition_path.write_text(FLOW_YAML, encoding="utf-8")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
encoding="utf-8",
@@ -114,4 +117,132 @@ def test_configured_project_declarative_flow(
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
- assert configured_project_declarative_flow() == "flow.yaml"
+ assert configured_project_declarative_flow() == definition_path.resolve()
+
+
+@pytest.mark.parametrize(
+ ("definition", "expected_error"),
+ [
+ ("C:/tmp/flow.yaml", "must be relative to the project root"),
+ ("~/flow.yaml", "must be a project-local path"),
+ ("../flow.yaml", "must resolve inside the project root"),
+ ],
+)
+def test_configured_project_declarative_flow_rejects_unsafe_paths(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ definition: str,
+ expected_error: str,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "pyproject.toml").write_text(
+ f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ with pytest.raises(click.UsageError) as exc_info:
+ configured_project_declarative_flow()
+
+ assert expected_error in exc_info.value.message
+
+
+def test_configured_project_declarative_flow_allows_normalized_project_path(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ definition_path = tmp_path / "flow.yaml"
+ definition_path.write_text(FLOW_YAML, encoding="utf-8")
+ (tmp_path / "src").mkdir()
+ (tmp_path / "pyproject.toml").write_text(
+ '[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ assert configured_project_declarative_flow() == definition_path.resolve()
+
+
+def test_configured_project_declarative_flow_rejects_absolute_path(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ definition = tmp_path / "flow.yaml"
+ (tmp_path / "pyproject.toml").write_text(
+ f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ with pytest.raises(click.UsageError) as exc_info:
+ configured_project_declarative_flow()
+
+ assert "must be relative to the project root" in exc_info.value.message
+
+
+def test_configured_project_declarative_flow_rejects_symlink_escape(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ outside_definition = tmp_path.parent / "outside-flow.yaml"
+ outside_definition.write_text(FLOW_YAML, encoding="utf-8")
+ link = tmp_path / "flow.yaml"
+ try:
+ link.symlink_to(outside_definition)
+ except (NotImplementedError, OSError) as exc:
+ pytest.skip(f"symlinks unavailable: {exc}")
+
+ (tmp_path / "pyproject.toml").write_text(
+ '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ with pytest.raises(click.UsageError) as exc_info:
+ configured_project_declarative_flow()
+
+ assert "must resolve inside the project root" in exc_info.value.message
+
+
+def test_configured_project_declarative_flow_rejects_missing_file(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "pyproject.toml").write_text(
+ '[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ with pytest.raises(click.UsageError) as exc_info:
+ configured_project_declarative_flow()
+
+ assert "must point to an existing file" in exc_info.value.message
+
+
+def test_configured_project_declarative_flow_rejects_directory(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "flow.yaml").mkdir()
+ (tmp_path / "pyproject.toml").write_text(
+ '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
+ encoding="utf-8",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ with pytest.raises(click.UsageError) as exc_info:
+ configured_project_declarative_flow()
+
+ assert "must point to a regular file" in exc_info.value.message
diff --git a/lib/cli/tests/test_kickoff_flow.py b/lib/cli/tests/test_kickoff_flow.py
new file mode 100644
index 000000000..52eb299ee
--- /dev/null
+++ b/lib/cli/tests/test_kickoff_flow.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import sys
+
+from crewai_cli import kickoff_flow
+
+
+def test_loads_conversational_flow_from_kickoff_script(tmp_path, monkeypatch) -> None:
+ package_dir = tmp_path / "src" / "demo_chat"
+ package_dir.mkdir(parents=True)
+ (package_dir / "__init__.py").write_text("")
+ (package_dir / "main.py").write_text(
+ "\n".join(
+ [
+ "from crewai.flow import Flow",
+ "",
+ "class DemoChatFlow(Flow):",
+ " conversational = True",
+ ]
+ )
+ )
+ (tmp_path / "pyproject.toml").write_text(
+ "\n".join(
+ [
+ "[project]",
+ 'name = "demo-chat"',
+ "[project.scripts]",
+ 'kickoff = "demo_chat.main:kickoff"',
+ ]
+ )
+ )
+ monkeypatch.chdir(tmp_path)
+ sys.modules.pop("demo_chat.main", None)
+ sys.modules.pop("demo_chat", None)
+
+ flow = kickoff_flow._load_conversational_flow_from_kickoff_script()
+
+ assert flow is not None
+ assert type(flow).__name__ == "DemoChatFlow"
+ assert flow.conversational is True
+
+
+def test_kickoff_flow_falls_back_to_uv_when_no_conversational_flow(
+ monkeypatch,
+) -> None:
+ calls: list[list[str]] = []
+
+ def fake_run(command, capture_output, text, check):
+ calls.append(command)
+
+ class Result:
+ stderr = ""
+
+ return Result()
+
+ monkeypatch.setattr(
+ kickoff_flow, "_load_conversational_flow_from_kickoff_script", lambda: None
+ )
+ monkeypatch.setattr(kickoff_flow.subprocess, "run", fake_run)
+
+ kickoff_flow.kickoff_flow()
+
+ assert calls == [["uv", "run", "kickoff"]]
diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py
index c51fc16c5..6db073919 100644
--- a/lib/cli/tests/test_run_crew.py
+++ b/lib/cli/tests/test_run_crew.py
@@ -645,6 +645,10 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
+ monkeypatch.setattr(
+ "crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
+ lambda: None,
+ )
run_crew_module.run_crew()
@@ -652,6 +656,41 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
assert calls == [("kickoff", {"entity_type": "flow"})]
+def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys):
+ class Flow:
+ pass
+
+ flow = Flow()
+ calls = []
+
+ monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
+ monkeypatch.setattr(
+ run_crew_module,
+ "read_toml",
+ lambda: {"tool": {"crewai": {"type": "flow"}}},
+ )
+ monkeypatch.setattr(
+ "crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
+ lambda: flow,
+ )
+ monkeypatch.setattr(
+ "crewai_cli.kickoff_flow._run_conversational_flow_tui",
+ lambda loaded_flow: calls.append(loaded_flow),
+ )
+ monkeypatch.setattr(
+ run_crew_module,
+ "_execute_uv_script",
+ lambda *_args, **_kwargs: pytest.fail(
+ "conversational flows must use the TUI"
+ ),
+ )
+
+ run_crew_module.run_crew()
+
+ assert capsys.readouterr().out == ""
+ assert calls == [flow]
+
+
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
@@ -666,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
assert "--filename can only be used when running crews" in exc_info.value.message
-def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
+def test_run_crew_runs_configured_declarative_flow_project(
+ monkeypatch, tmp_path: Path, capsys
+):
calls = []
+ monkeypatch.chdir(tmp_path)
+ definition_path = tmp_path / "flow.yaml"
+ definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
@@ -695,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
- assert calls == [("flow.yaml", None)]
+ assert calls == [(definition_path.resolve(), None)]
diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py
index 7b09b91f5..928b412af 100644
--- a/lib/crewai-core/src/crewai_core/__init__.py
+++ b/lib/crewai-core/src/crewai_core/__init__.py
@@ -1 +1 @@
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"
diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py
index 4e4030d60..2c9bac458 100644
--- a/lib/crewai-files/src/crewai_files/__init__.py
+++ b/lib/crewai-files/src/crewai_files/__init__.py
@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"
diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml
index f0487171a..e6eac89c1 100644
--- a/lib/crewai-tools/pyproject.toml
+++ b/lib/crewai-tools/pyproject.toml
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
- "crewai==1.14.8a3",
+ "crewai==1.14.8a4",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py
index 438943ebc..65c3a5d87 100644
--- a/lib/crewai-tools/src/crewai_tools/__init__.py
+++ b/lib/crewai-tools/src/crewai_tools/__init__.py
@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"
diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml
index fcdc1bd01..2cbaa1cb3 100644
--- a/lib/crewai/pyproject.toml
+++ b/lib/crewai/pyproject.toml
@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
- "crewai-core==1.14.8a3",
- "crewai-cli==1.14.8a3",
+ "crewai-core==1.14.8a4",
+ "crewai-cli==1.14.8a4",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
- "crewai-tools==1.14.8a3",
+ "crewai-tools==1.14.8a4",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py
index 40157afa9..b49b462e8 100644
--- a/lib/crewai/src/crewai/__init__.py
+++ b/lib/crewai/src/crewai/__init__.py
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),
diff --git a/lib/crewai/src/crewai/experimental/skills/cache.py b/lib/crewai/src/crewai/experimental/skills/cache.py
index e9752c4e8..065c2c521 100644
--- a/lib/crewai/src/crewai/experimental/skills/cache.py
+++ b/lib/crewai/src/crewai/experimental/skills/cache.py
@@ -9,6 +9,7 @@ from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
+import os
from pathlib import Path
import tarfile
from typing import TypedDict
@@ -127,12 +128,36 @@ class SkillCacheManager:
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
- """Path-traversal-safe extraction for Python < 3.12."""
+ """Path-traversal-safe extraction for Python versions without tar filters.
+
+ Validates both the member's own path and, for symlink/hardlink members,
+ the link target. Without the link-target check a malicious archive can
+ plant a symlink that escapes ``dest`` followed by a regular member written
+ through that link, escaping ``dest`` even though every member name resolves
+ inside it. This mirrors the protection that
+ ``tarfile.extractall(..., filter="data")`` provides when available.
+ """
dest_resolved = dest.resolve()
for member in tf.getmembers():
member_path = (dest / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
+ if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
+ raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
+ if member.issym() or member.islnk():
+ link_target = member.linkname
+ if os.path.isabs(link_target):
+ raise ValueError(
+ f"Blocked link target escaping destination: "
+ f"{member.name!r} -> {link_target!r}"
+ )
+ anchor = dest if member.islnk() else (dest / member.name).parent
+ resolved_target = (anchor / link_target).resolve()
+ if not resolved_target.is_relative_to(dest_resolved):
+ raise ValueError(
+ f"Blocked link target escaping destination: "
+ f"{member.name!r} -> {link_target!r}"
+ )
tf.extractall(dest) # noqa: S202
diff --git a/lib/crewai/tests/experimental/skills/test_cache.py b/lib/crewai/tests/experimental/skills/test_cache.py
index 8b458bb3e..b6601dccf 100644
--- a/lib/crewai/tests/experimental/skills/test_cache.py
+++ b/lib/crewai/tests/experimental/skills/test_cache.py
@@ -8,7 +8,9 @@ import json
import tarfile
from pathlib import Path
-from crewai.experimental.skills.cache import SkillCacheManager
+import pytest
+
+from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall
def _make_tar_gz(files: dict[str, str]) -> bytes:
@@ -35,6 +37,15 @@ def _make_tar_gz(files: dict[str, str]) -> bytes:
return out.getvalue()
+def _tar_from_members(build) -> tarfile.TarFile:
+ """Build an in-memory tar archive via `build(tf)` and return it for reading."""
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w") as tf:
+ build(tf)
+ buf.seek(0)
+ return tarfile.open(fileobj=buf, mode="r")
+
+
class TestSkillCacheManager:
def test_get_cached_path_missing(self, tmp_path: Path) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
@@ -113,3 +124,85 @@ class TestSkillCacheManager:
dest = cache.store("acme", "my-skill", None, archive)
meta = json.loads((dest / ".crewai_meta.json").read_text())
assert meta["version"] is None
+
+
+def test_safe_extractall_blocks_symlink_escaping_cache_destination(
+ tmp_path: Path,
+) -> None:
+ """A symlink whose target escapes dest is rejected before extraction."""
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ link = tarfile.TarInfo("link")
+ link.type = tarfile.SYMTYPE
+ link.linkname = str(outside)
+ tf.addfile(link)
+ payload = b"pwned"
+ info = tarfile.TarInfo("link/evil.txt")
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="escaping destination"):
+ _safe_extractall(tf, dest)
+
+ assert not (outside / "evil.txt").exists()
+
+
+def test_safe_extractall_blocks_hardlink_escaping_cache_destination(
+ tmp_path: Path,
+) -> None:
+ """A hardlink whose target escapes dest is rejected."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ link = tarfile.TarInfo("escape")
+ link.type = tarfile.LNKTYPE
+ link.linkname = "../outside.txt"
+ tf.addfile(link)
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="escaping destination"):
+ _safe_extractall(tf, dest)
+
+
+def test_safe_extractall_blocks_special_cache_tar_member(tmp_path: Path) -> None:
+ """Special tar members such as FIFOs are rejected."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ fifo = tarfile.TarInfo("pipe")
+ fifo.type = tarfile.FIFOTYPE
+ tf.addfile(fifo)
+
+ with _tar_from_members(build) as tf:
+ with pytest.raises(ValueError, match="unsupported tar member"):
+ _safe_extractall(tf, dest)
+
+
+def test_safe_extractall_allows_benign_cache_symlink(tmp_path: Path) -> None:
+ """A symlink that stays within dest is permitted."""
+ dest = tmp_path / "dest"
+ dest.mkdir()
+
+ def build(tf: tarfile.TarFile) -> None:
+ payload = b"hi"
+ info = tarfile.TarInfo("real.txt")
+ info.size = len(payload)
+ tf.addfile(info, io.BytesIO(payload))
+ link = tarfile.TarInfo("alias.txt")
+ link.type = tarfile.SYMTYPE
+ link.linkname = "real.txt"
+ tf.addfile(link)
+
+ with _tar_from_members(build) as tf:
+ _safe_extractall(tf, dest)
+
+ assert (dest / "real.txt").read_bytes() == b"hi"
+ assert (dest / "alias.txt").is_symlink()
+ assert (dest / "alias.txt").readlink() == Path("real.txt")
diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py
index 71b581c0f..bbffc72b1 100644
--- a/lib/devtools/src/crewai_devtools/__init__.py
+++ b/lib/devtools/src/crewai_devtools/__init__.py
@@ -1,3 +1,3 @@
"""CrewAI development tools."""
-__version__ = "1.14.8a3"
+__version__ = "1.14.8a4"