Repository: crewAIInc/crewAI
Pull request #1376 (2024-10-01 11:31:23)

Update README 
1.  Updated all instances of `crewAI` to `CrewAI`
2. Updated the `Setting Your Crew` example with a full working example following the YAML config format. This one specifically should improve DevX and onboarding with a real working example end-to-end.


Patch:
diff --git a/README.md b/README.md
index 8d081a44..ac7da2f9 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
 <div align="center">
 
-![Logo of crewAI, two people rowing on a boat](./docs/crewai_logo.png)
+![Logo of CrewAI, two people rowing on a boat](./docs/crewai_logo.png)
 
-# **crewAI**
+# **CrewAI**
 
-🤖 **crewAI**: Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks.
+🤖 **CrewAI**: Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks.
 
 <h3>
 
@@ -44,83 +44,222 @@ To get started with CrewAI, follow these simple steps:
 
 ### 1. Installation
 
+Ensure you have Python >=3.10 <=3.13 installed on your system. CrewAI uses [Poetry](https://python-poetry.org/) for dependency management and package handling, offering a seamless setup and execution experience.
+
+First, if you haven't already, install Poetry:
+
+```bash
+pip install poetry
+```
+
+Then, install CrewAI:
+
 ```shell
 pip install crewai
 ```
 
-If you want to install the 'crewai' package along with its optional features that include additional tools for agents, you can do so by using the following command: pip install 'crewai[tools]'. This command installs the basic package and also adds extra components which require more dependencies to function."
+If you want to install the 'crewai' package along with its optional features that include additional tools for agents, you can do so by using the following command:
 
 ```shell
 pip install 'crewai[tools]'
 ```
+The command above installs the basic package and also adds extra components which require more dependencies to function.
+
+### 2. Setting Up Your Crew with the YAML Configuration
+
+To create a new CrewAI project, run the following CLI (Command Line Interface) command:
+
+```shell
+crewai create crew <project_name>
+```
+
+This command creates a new project folder with the following structure:
+
+```
+my_project/
+├── .gitignore
+├── pyproject.toml
+├── README.md
+├── .env
+└── src/
+    └── my_project/
+        ├── __init__.py
+        ├── main.py
+        ├── crew.py
+        ├── tools/
+        │   ├── custom_tool.py
+        │   └── __init__.py
+        └── config/
+            ├── agents.yaml
+            └── tasks.yaml
+```
+
+You can now start developing your crew by editing the files in the `src/my_project` folder. The `main.py` file is the entry point of the project, the `crew.py` file is where you define your crew, the `agents.yaml` file is where you define your agents, and the `tasks.yaml` file is where you define your tasks.
+
+#### To customize your project, you can:
+
+- Modify `src/my_project/config/agents.yaml` to define your agents.
+- Modify `src/my_project/config/tasks.yaml` to define your tasks.
+- Modify `src/my_project/crew.py` to add your own logic, tools, and specific arguments.
+- Modify `src/my_project/main.py` to add custom inputs for your agents and tasks.
+- Add your environment variables into the `.env` file.
+
+#### Example of a simple crew with a sequential process:
+
+Instatiate your crew:
+
+```shell
+crewai create crew latest-ai-development
+```
 
-### 2. Setting Up Your Crew
+Modify the files as needed to fit your use case:
+
+**agents.yaml**
+
+```yaml
+# src/my_project/config/agents.yaml
+researcher:
+  role: >
+    {topic} Senior Data Researcher
+  goal: >
+    Uncover cutting-edge developments in {topic}
+  backstory: >
+    You're a seasoned researcher with a knack for uncovering the latest
+    developments in {topic}. Known for your ability to find the most relevant
+    information and present it in a clear and concise manner.
+      
+reporting_analyst:
+  role: >
+    {topic} Reporting Analyst
+  goal: >
+    Create detailed reports based on {topic} data analysis and research findings
+  backstory: >
+    You're a meticulous analyst with a keen eye for detail. You're known for
+    your ability to turn complex data into clear and concise reports, making
+    it easy for others to understand and act on the information you provide.
+```
+
+**tasks.yaml**
+
+```yaml
+# src/my_project/config/tasks.yaml
+research_task:
+  description: >
+    Conduct a thorough research about {topic}
+    Make sure you find any interesting and relevant information given
+    the current year is 2024.
+  expected_output: >
+    A list with 10 bullet points of the most relevant information about {topic}
+  agent: researcher
+
+reporting_task:
+  description: >
+    Review the context you got and expand each topic into a full section for a report.
+    Make sure the report is detailed and contains any and all relevant information.
+  expected_output: >
+    A fully fledge reports with the mains topics, each with a full section of information.
+    Formatted as markdown without '```'
+  agent: reporting_analyst
+  output_file: report.md
+```
+
+**crew.py**
 
 ```python
-import os
-from crewai import Agent, Task, Crew, Process
+# src/my_project/crew.py
+from crewai import Agent, Crew, Process, Task
+from crewai.project import CrewBase, agent, crew, task
 from crewai_tools import SerperDevTool
 
-os.environ["OPENAI_API_KEY"] = "YOUR_API_KEY"
-os.environ["SERPER_API_KEY"] = "Your Key" # serper.dev API key
-
-# It can be a local model through Ollama / LM Studio or a remote
-# model like OpenAI, Mistral, Antrophic or others (https://docs.crewai.com/how-to/LLM-Connections/)
-
-# Define your agents with roles and goals
-researcher = Agent(
-  role='Senior Research Analyst',
-  goal='Uncover cutting-edge developments in AI and data science',
-  backstory="""You work at a leading tech think tank.
-  Your expertise lies in identifying emerging trends.
-  You have a knack for dissecting complex data and presenting actionable insights.""",
-  verbose=True,
-  allow_delegation=False,
-  # You can pass an optional llm attribute specifying what model you wanna use.
-  # llm=ChatOpenAI(model_name="gpt-3.5", temperature=0.7),
-  tools=[SerperDevTool()]
-)
-writer = Agent(
-  role='Tech Content Strategist',
-  goal='Craft compelling content on tech advancements',
-  backstory="""You are a renowned Content Strategist, known for your insightful and engaging articles.
-  You transform complex concepts into compelling narratives.""",
-  verbose=True,
-  allow_delegation=True
-)
-
-# Create tasks for your agents
-task1 = Task(
-  description="""Conduct a comprehensive analysis of the latest advancements in AI in 2024.
-  Identify key trends, breakthrough technologies, and potential industry impacts.""",
-  expected_output="Full analysis report in bullet points",
-  agent=researcher
-)
-
-task2 = Task(
-  description="""Using the insights provided, develop an engaging blog
-  post that highlights the most significant AI advancements.
-  Your post should be informative yet accessible, catering to a tech-savvy audience.
-  Make it sound cool, avoid complex words so it doesn't sound like AI.""",
-  expected_output="Full blog post of at least 4 paragraphs",
-  agent=writer
-)
-
-# Instantiate your crew with a sequential process
-crew = Crew(
-  agents=[researcher, writer],
-  tasks=[task1, task2],
-  verbose=True,
-  process = Process.sequential
-)
-
-# Get your crew to work!
-result = crew.kickoff()
-
-print("######################")
-print(result)
+@CrewBase
+class LatestAiDevelopmentCrew():
+	"""LatestAiDevelopment crew"""
+
+	@agent
+	def researcher(self) -> Agent:
+		return Agent(
+			config=self.agents_config['researcher'],
+			verbose=True,
+			tools=[SerperDevTool()]
+		)
+
+	@agent
+	def reporting_analyst(self) -> Agent:
+		return Agent(
+			config=self.agents_config['reporting_analyst'],
+			verbose=True
+		)
+
+	@task
+	def research_task(self) -> Task:
+		return Task(
+			config=self.tasks_config['research_task'],
+		)
+
+	@task
+	def reporting_task(self) -> Task:
+		return Task(
+			config=self.tasks_config['reporting_task'],
+			output_file='report.md'
+		)
+
+	@crew
+	def crew(self) -> Crew:
+		"""Creates the LatestAiDevelopment crew"""
+		return Crew(
+			agents=self.agents, # Automatically created by the @agent decorator
+			tasks=self.tasks, # Automatically created by the @task decorator
+			process=Process.sequential,
+			verbose=True,
+		) 
+```
+
+**main.py**
+
+```python
+#!/usr/bin/env python
+# src/my_project/main.py
+import sys
+from latest_ai_development.crew import LatestAiDevelopmentCrew
+
+def run():
+    """
+    Run the crew.
+    """
+    inputs = {
+        'topic': 'AI Agents'
+    }
+    LatestAiDevelopmentCrew().crew().kickoff(inputs=inputs)
 ```
 
+### 3. Running Your Crew
+
+Before running your crew, make sure you have the following keys set as environment variables in your `.env` file:
+
+- An [OpenAI API key](https://platform.openai.com/account/api-keys) (or other LLM API key): `OPENAI_API_KEY=sk-...`
+- A [Serper.dev](https://serper.dev/) API key: `SERPER_API_KEY=YOUR_KEY_HERE`
+
+Lock the dependencies and install them by using the CLI command but first, navigate to your project directory:
+
+```shell
+cd my_project
+crewai install
+```
+
+To run your crew, execute the following command in the root of your project:
+
+```bash
+crewai run
+```
+
+or
+
+```bash
+python src/my_project/main.py
+```
+
+You should see the output in the console and the `report.md` file should be created in the root of your project with the full final report.
+
 In addition to the sequential process, you can use the hierarchical process, which automatically assigns a manager to the defined crew to properly coordinate the planning and execution of tasks through delegation and validation of results. [See more about the processes here](https://docs.crewai.com/core-concepts/Processes/).
 
 ## Key Features
@@ -131,13 +270,13 @@ In addition to the sequential process, you can use the hierarchical process, whi
 - **Processes Driven**: Currently only supports `sequential` task execution and `hierarchical` processes, but more complex processes like consensual and autonomous are being worked on.
 - **Save output as file**: Save the output of individual tasks as a file, so you can use it later.
 - **Parse output as Pydantic or Json**: Parse the output of individual tasks as a Pydantic model or as a Json if you want to.
-- **Works with Open Source Models**: Run your crew using Open AI or open source models refer to the [Connect crewAI to LLMs](https://docs.crewai.com/how-to/LLM-Connections/) page for details on configuring your agents' connections to models, even ones running locally!
+- **Works with Open Source Models**: Run your crew using Open AI or open source models refer to the [Connect CrewAI to LLMs](https://docs.crewai.com/how-to/LLM-Connections/) page for details on configuring your agents' connections to models, even ones running locally!
 
 ![CrewAI Mind Map](./docs/crewAI-mindmap.png "CrewAI Mind Map")
 
 ## Examples
 
-You can test different real life examples of AI crews in the [crewAI-examples repo](https://github.com/crewAIInc/crewAI-examples?tab=readme-ov-file):
+You can test different real life examples of AI crews in the [CrewAI-examples repo](https://github.com/crewAIInc/crewAI-examples?tab=readme-ov-file):
 
 - [Landing Page Generator](https://github.com/crewAIInc/crewAI-examples/tree/main/landing_page_generator)
 - [Having Human input on the execution](https://docs.crewai.com/how-to/Human-Input-on-Execution)
@@ -168,9 +307,9 @@ You can test different real life examples of AI crews in the [crewAI-examples re
 
 ## Connecting Your Crew to a Model
 
-crewAI supports using various LLMs through a variety of connection options. By default your agents will use the OpenAI API when querying the model. However, there are several other ways to allow your agents to connect to models. For example, you can configure your agents to use a local model via the Ollama tool.
+CrewAI supports using various LLMs through a variety of connection options. By default your agents will use the OpenAI API when querying the model. However, there are several other ways to allow your agents to connect to models. For example, you can configure your agents to use a local model via the Ollama tool.
 
-Please refer to the [Connect crewAI to LLMs](https://docs.crewai.com/how-to/LLM-Connections/) page for details on configuring you agents' connections to models.
+Please refer to the [Connect CrewAI to LLMs](https://docs.crewai.com/how-to/LLM-Connections/) page for details on configuring you agents' connections to models.
 
 ## How CrewAI Compares
 
@@ -241,7 +380,7 @@ It's pivotal to understand that **NO data is collected** concerning prompts, tas
 
 Data collected includes:
 
-- Version of crewAI
+- Version of CrewAI
   - So we can understand how many users are using the latest version
 - Version of Python
   - So we can decide on what versions to better support
@@ -266,7 +405,7 @@ Users can opt-in to Further Telemetry, sharing the complete telemetry data by se
 
 ## License
 
-CrewAI is released under the MIT License.
+CrewAI is released under the [MIT License](https://github.com/crewAIInc/crewAI/blob/main/LICENSE).
 
 ## Frequently Asked Questions (FAQ)
 
@@ -299,7 +438,7 @@ A: Yes, CrewAI is open-source and welcomes contributions from the community.
 A: CrewAI uses anonymous telemetry to collect usage data for improvement purposes. No sensitive data (like prompts, task descriptions, or API calls) is collected. Users can opt-in to share more detailed data by setting `share_crew=True` on their Crews.
 
 ### Q: Where can I find examples of CrewAI in action?
-A: You can find various real-life examples in the [crewAI-examples repository](https://github.com/crewAIInc/crewAI-examples), including trip planners, stock analysis tools, and more.
+A: You can find various real-life examples in the [CrewAI-examples repository](https://github.com/crewAIInc/crewAI-examples), including trip planners, stock analysis tools, and more.
 
 ### Q: How can I contribute to CrewAI?
 A: Contributions are welcome! You can fork the repository, create a new branch for your feature, add your improvement, and send a pull request. Check the Contribution section in the README for more details.
diff --git a/poetry.lock b/poetry.lock
index a4d1b8ca..753bbd8c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1581,12 +1581,12 @@ files = [
 google-auth = ">=2.14.1,<3.0.dev0"
 googleapis-common-protos = ">=1.56.2,<2.0.dev0"
 grpcio = [
-    {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
     {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""},
+    {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
 ]
 grpcio-status = [
-    {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
     {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""},
+    {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""},
 ]
 proto-plus = ">=1.22.3,<2.0.0dev"
 protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
@@ -1936,13 +1936,13 @@ test = ["objgraph", "psutil"]
 
 [[package]]
 name = "griffe"
-version = "1.3.1"
+version = "1.3.2"
 description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "griffe-1.3.1-py3-none-any.whl", hash = "sha256:940aeb630bc3054b4369567f150b6365be6f11eef46b0ed8623aea96e6d17b19"},
-    {file = "griffe-1.3.1.tar.gz", hash = "sha256:3f86a716b631a4c0f96a43cb75d05d3c85975003c20540426c0eba3b0581c56a"},
+    {file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"},
+    {file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"},
 ]
 
 [package.dependencies]
@@ -2148,13 +2148,13 @@ files = [
 
 [[package]]
 name = "httpcore"
-version = "1.0.5"
+version = "1.0.6"
 description = "A minimal low-level HTTP client."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
-    {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
+    {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"},
+    {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"},
 ]
 
 [package.dependencies]
@@ -2165,7 +2165,7 @@ h11 = ">=0.13,<0.15"
 asyncio = ["anyio (>=4.0,<5.0)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
-trio = ["trio (>=0.22.0,<0.26.0)"]
+trio = ["trio (>=0.22.0,<1.0)"]
 
 [[package]]
 name = "httptools"
@@ -3891,13 +3891,13 @@ sympy = "*"
 
 [[package]]
 name = "openai"
-version = "1.50.2"
+version = "1.51.0"
 description = "The official Python library for the openai API"
 optional = false
 python-versions = ">=3.7.1"
 files = [
-    {file = "openai-1.50.2-py3-none-any.whl", hash = "sha256:822dd2051baa3393d0d5406990611975dd6f533020dc9375a34d4fe67e8b75f7"},
-    {file = "openai-1.50.2.tar.gz", hash = "sha256:3987ae027152fc8bea745d60b02c8f4c4a76e1b5c70e73565fa556db6f78c9e6"},
+    {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"},
+    {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"},
 ]
 
 [package.dependencies]
@@ -4266,8 +4266,8 @@ files = [
 
 [package.dependencies]
 numpy = [
-    {version = ">=1.23.2", markers = "python_version == \"3.11\""},
     {version = ">=1.22.4", markers = "python_version < \"3.11\""},
+    {version = ">=1.23.2", markers = "python_version == \"3.11\""},
     {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
 ]
 python-dateutil = ">=2.8.2"
@@ -5058,13 +5058,13 @@ torch = ["torch"]
 
 [[package]]
 name = "pymdown-extensions"
-version = "10.11.1"
+version = "10.11.2"
 description = "Extension pack for Python Markdown."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pymdown_extensions-10.11.1-py3-none-any.whl", hash = "sha256:a2b28f5786e041f19cb5bb30a1c2c853668a7099da8e3dd822a5ad05f2e855e3"},
-    {file = "pymdown_extensions-10.11.1.tar.gz", hash = "sha256:a8836e955851542fa2625d04d59fdf97125ca001377478ed5618e04f9183a59a"},
+    {file = "pymdown_extensions-10.11.2-py3-none-any.whl", hash = "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf"},
+    {file = "pymdown_extensions-10.11.2.tar.gz", hash = "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049"},
 ]
 
 [package.dependencies]
@@ -5212,6 +5212,25 @@ pytest = ">=7.0.0,<9"
 docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
 testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
 
+[[package]]
+name = "pytest-subprocess"
+version = "1.5.2"
+description = "A plugin to fake subprocess for pytest"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pytest_subprocess-1.5.2-py3-none-any.whl", hash = "sha256:23ac7732aa8bd45f1757265b1316eb72a7f55b41fb21e2ca22e149ba3629fa46"},
+    {file = "pytest_subprocess-1.5.2.tar.gz", hash = "sha256:ad3ca8a35e798bf9c82d9f16d88700b30d98c5a28236117b86c5d6e581a8ed97"},
+]
+
+[package.dependencies]
+pytest = ">=4.0.0"
+
+[package.extras]
+dev = ["changelogd", "nox"]
+docs = ["changelogd", "furo", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-napoleon"]
+test = ["Pygments (>=2.0)", "anyio", "coverage", "docutils (>=0.12)", "pytest (>=4.0)", "pytest-asyncio (>=0.15.1)", "pytest-rerunfailures", "pytest-timeout"]
+
 [[package]]
 name = "pytest-vcr"
 version = "1.0.2"
@@ -5747,18 +5766,19 @@ py = ">=1.4.26,<2.0.0"
 
 [[package]]
 name = "rich"
-version = "13.8.1"
+version = "13.9.1"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
 optional = false
-python-versions = ">=3.7.0"
+python-versions = ">=3.8.0"
 files = [
-    {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"},
-    {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"},
+    {file = "rich-13.9.1-py3-none-any.whl", hash = "sha256:b340e739f30aa58921dc477b8adaa9ecdb7cecc217be01d93730ee1bc8aa83be"},
+    {file = "rich-13.9.1.tar.gz", hash = "sha256:097cffdf85db1babe30cc7deba5ab3a29e1b9885047dab24c57e9a7f8a9c1466"},
 ]
 
 [package.dependencies]
 markdown-it-py = ">=2.2.0"
 pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
 jupyter = ["ipywidgets (>=7.5.1,<9)"]
@@ -7330,4 +7350,4 @@ tools = ["crewai-tools"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.10,<=3.13"
-content-hash = "b008b28add072e8f002aa90d230b20027f0ecffcd3c4b3fe4ec954f5ac7c46ca"
+content-hash = "0b833460455da407e9b73fe545bb8dfccc114384d3ce1aa66a1d2fc83afb00f7"
diff --git a/pyproject.toml b/pyproject.toml
index 985f5709..cd9c65aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "crewai"
-version = "0.66.0"
+version = "0.67.1"
 description = "Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks."
 authors = ["Joao Moura <joao@crewai.com>"]
 readme = "README.md"
@@ -57,6 +57,7 @@ pytest = "^8.0.0"
 pytest-vcr = "^1.0.2"
 python-dotenv = "1.0.0"
 pytest-asyncio = "^0.23.7"
+pytest-subprocess = "^1.5.2"
 
 [tool.poetry.scripts]
 crewai = "crewai.cli.cli:crewai"
diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py
index 22e1aad3..c8660f34 100644
--- a/src/crewai/cli/cli.py
+++ b/src/crewai/cli/cli.py
@@ -12,12 +12,14 @@ from crewai.memory.storage.kickoff_task_outputs_storage import (
 
 from .authentication.main import AuthenticationCommand
 from .deploy.main import DeployCommand
-from .tools.main import ToolCommand
 from .evaluate_crew import evaluate_crew
 from .install_crew import install_crew
+from .plot_flow import plot_flow
 from .replay_from_task import replay_task_command
 from .reset_memories_command import reset_memories_command
 from .run_crew import run_crew
+from .run_flow import run_flow
+from .tools.main import ToolCommand
 from .train_crew import train_crew
 
 
@@ -258,10 +260,18 @@ def deploy_remove(uuid: Optional[str]):
     deploy_cmd.remove_crew(uuid=uuid)
 
 
+@tool.command(name="create")
+@click.argument("handle")
+def tool_create(handle: str):
+    tool_cmd = ToolCommand()
+    tool_cmd.create(handle)
+
+
 @tool.command(name="install")
 @click.argument("handle")
 def tool_install(handle: str):
     tool_cmd = ToolCommand()
+    tool_cmd.login()
     tool_cmd.install(handle)
 
 
@@ -270,8 +280,29 @@ def tool_install(handle: str):
 @click.option("--private", "is_public", flag_value=False)
 def tool_publish(is_public: bool):
     tool_cmd = ToolCommand()
+    tool_cmd.login()
     tool_cmd.publish(is_public)
 
 
+@crewai.group()
+def flow():
+    """Flow related commands."""
+    pass
+
+
+@flow.command(name="run")
+def flow_run():
+    """Run the Flow."""
+    click.echo("Running the Flow")
+    run_flow()
+
+
+@flow.command(name="plot")
+def flow_plot():
+    """Plot the Flow."""
+    click.echo("Plotting the Flow")
+    plot_flow()
+
+
 if __name__ == "__main__":
     crewai()
diff --git a/src/crewai/cli/command.py b/src/crewai/cli/command.py
index 0b12b908..f05fe237 100644
--- a/src/crewai/cli/command.py
+++ b/src/crewai/cli/command.py
@@ -1,4 +1,5 @@
-from typing import Dict, Any
+import requests
+from requests.exceptions import JSONDecodeError
 from rich.console import Console
 from crewai.cli.plus_api import PlusAPI
 from crewai.cli.utils import get_auth_token
@@ -27,14 +28,44 @@ class PlusAPIMixin:
             console.print("Run 'crewai signup' to sign up/login.", style="bold green")
             raise SystemExit
 
-    def _handle_plus_api_error(self, json_response: Dict[str, Any]) -> None:
+    def _validate_response(self, response: requests.Response) -> None:
         """
         Handle and display error messages from API responses.
 
         Args:
-            json_response (Dict[str, Any]): The JSON response containing error information.
+            response (requests.Response): The response from the Plus API
         """
-        error = json_response.get("error", "Unknown error")
-        message = json_response.get("message", "No message provided")
-        console.print(f"Error: {error}", style="bold red")
-        console.print(f"Message: {message}", style="bold red")
+        try:
+            json_response = response.json()
+        except (JSONDecodeError, ValueError):
+            console.print(
+                "Failed to parse response from Enterprise API failed. Details:",
+                style="bold red",
+            )
+            console.print(f"Status Code: {response.status_code}")
+            console.print(f"Response:\n{response.content}")
+            raise SystemExit
+
+        if response.status_code == 422:
+            console.print(
+                "Failed to complete operation. Please fix the following errors:",
+                style="bold red",
+            )
+            for field, messages in json_response.items():
+                for message in messages:
+                    console.print(
+                        f"* [bold red]{field.capitalize()}[/bold red] {message}"
+                    )
+            raise SystemExit
+
+        if not response.ok:
+            console.print(
+                "Request to Enterprise API failed. Details:", style="bold red"
+            )
+            details = (
+                json_response.get("error")
+                or json_response.get("message")
+                or response.content
+            )
+            console.print(f"{details}")
+            raise SystemExit
diff --git a/src/crewai/cli/deploy/main.py b/src/crewai/cli/deploy/main.py
index d6c9d8fe..48695920 100644
--- a/src/crewai/cli/deploy/main.py
+++ b/src/crewai/cli/deploy/main.py
@@ -2,12 +2,9 @@ from typing import Any, Dict, List, Optional
 
 from rich.console import Console
 
+from crewai.cli import git
 from crewai.cli.command import BaseCommand, PlusAPIMixin
-from crewai.cli.utils import (
-    fetch_and_json_env_file,
-    get_git_remote_url,
-    get_project_name,
-)
+from crewai.cli.utils import fetch_and_json_env_file, get_project_name
 
 console = Console()
 
@@ -79,11 +76,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
             self._standard_no_param_error_message()
             return
 
-        json_response = response.json()
-        if response.status_code == 200:
-            self._display_deployment_info(json_response)
-        else:
-            self._handle_plus_api_error(json_response)
+        self._validate_response(response)
+        self._display_deployment_info(response.json())
 
     def create_crew(self, confirm: bool = False) -> None:
         """
@@ -94,7 +88,11 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
         )
         console.print("Creating deployment...", style="bold blue")
         env_vars = fetch_and_json_env_file()
-        remote_repo_url = get_git_remote_url()
+
+        try:
+            remote_repo_url = git.Repository().origin_url()
+        except ValueError:
+            remote_repo_url = None
 
         if remote_repo_url is None:
             console.print("No remote repository URL found.", style="bold red")
@@ -106,12 +104,10 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
 
         self._confirm_input(env_vars, remote_repo_url, confirm)
         payload = self._create_payload(env_vars, remote_repo_url)
-
         response = self.plus_api_client.create_crew(payload)
-        if response.status_code == 201:
-            self._display_creation_success(response.json())
-        else:
-            self._handle_plus_api_error(response.json())
+
+        self._validate_response(response)
+        self._display_creation_success(response.json())
 
     def _confirm_input(
         self, env_vars: Dict[str, str], remote_repo_url: str, confirm: bool
@@ -218,11 +214,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
             self._standard_no_param_error_message()
             return
 
-        json_response = response.json()
-        if response.status_code == 200:
-            self._display_crew_status(json_response)
-        else:
-            self._handle_plus_api_error(json_response)
+        self._validate_response(response)
+        self._display_crew_status(response.json())
 
     def _display_crew_status(self, status_data: Dict[str, str]) -> None:
         """
@@ -253,10 +246,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
             self._standard_no_param_error_message()
             return
 
-        if response.status_code == 200:
-            self._display_logs(response.json())
-        else:
-            self._handle_plus_api_error(response.json())
+        self._validate_response(response)
+        self._display_logs(response.json())
 
     def remove_crew(self, uuid: Optional[str]) -> None:
         """
diff --git a/src/crewai/cli/git.py b/src/crewai/cli/git.py
new file mode 100644
index 00000000..94c3648b
--- /dev/null
+++ b/src/crewai/cli/git.py
@@ -0,0 +1,80 @@
+import subprocess
+
+
+class Repository:
+    def __init__(self, path="."):
+        self.path = path
+
+        if not self.is_git_installed():
+            raise ValueError("Git is not installed or not found in your PATH.")
+
+        if not self.is_git_repo():
+            raise ValueError(f"{self.path} is not a Git repository.")
+
+        self.fetch()
+
+    def is_git_installed(self) -> bool:
+        """Check if Git is installed and available in the system."""
+        try:
+            subprocess.run(
+                ["git", "--version"], capture_output=True, check=True, text=True
+            )
+            return True
+        except (subprocess.CalledProcessError, FileNotFoundError):
+            return False
+
+    def fetch(self) -> None:
+        """Fetch latest updates from the remote."""
+        subprocess.run(["git", "fetch"], cwd=self.path, check=True)
+
+    def status(self) -> str:
+        """Get the git status in porcelain format."""
+        return subprocess.check_output(
+            ["git", "status", "--branch", "--porcelain"],
+            cwd=self.path,
+            encoding="utf-8",
+        ).strip()
+
+    def is_git_repo(self) -> bool:
+        """Check if the current directory is a git repository."""
+        try:
+            subprocess.check_output(
+                ["git", "rev-parse", "--is-inside-work-tree"],
+                cwd=self.path,
+                encoding="utf-8",
+            )
+            return True
+        except subprocess.CalledProcessError:
+            return False
+
+    def has_uncommitted_changes(self) -> bool:
+        """Check if the repository has uncommitted changes."""
+        return len(self.status().splitlines()) > 1
+
+    def is_ahead_or_behind(self) -> bool:
+        """Check if the repository is ahead or behind the remote."""
+        for line in self.status().splitlines():
+            if line.startswith("##") and ("ahead" in line or "behind" in line):
+                return True
+        return False
+
+    def is_synced(self) -> bool:
+        """Return True if the Git repository is fully synced with the remote, False otherwise."""
+        if self.has_uncommitted_changes() or self.is_ahead_or_behind():
+            return False
+        else:
+            return True
+
+    def origin_url(self) -> str | None:
+        """Get the Git repository's remote URL."""
+        try:
+            result = subprocess.run(
+                ["git", "remote", "get-url", "origin"],
+                cwd=self.path,
+                capture_output=True,
+                text=True,
+                check=True,
+            )
+            return result.stdout.strip()
+        except subprocess.CalledProcessError:
+            return None
diff --git a/src/crewai/cli/plot_flow.py b/src/crewai/cli/plot_flow.py
new file mode 100644
index 00000000..bb7b2052
--- /dev/null
+++ b/src/crewai/cli/plot_flow.py
@@ -0,0 +1,23 @@
+import subprocess
+
+import click
+
+
+def plot_flow() -> None:
+    """
+    Plot the flow by running a command in the Poetry environment.
+    """
+    command = ["poetry", "run", "plot_flow"]
+
+    try:
+        result = subprocess.run(command, capture_output=False, text=True, check=True)
+
+        if result.stderr:
+            click.echo(result.stderr, err=True)
+
+    except subprocess.CalledProcessError as e:
+        click.echo(f"An error occurred while plotting 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/src/crewai/cli/plus_api.py b/src/crewai/cli/plus_api.py
index e72d27bf..0fa9ff3a 100644
--- a/src/crewai/cli/plus_api.py
+++ b/src/crewai/cli/plus_api.py
@@ -27,6 +27,9 @@ class PlusAPI:
         url = urljoin(self.base_url, endpoint)
         return requests.request(method, url, headers=self.headers, **kwargs)
 
+    def login_to_tool_repository(self):
+        return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login")
+
     def get_tool(self, handle: str):
         return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
 
diff --git a/src/crewai/cli/run_flow.py b/src/crewai/cli/run_flow.py
new file mode 100644
index 00000000..3a9e7281
--- /dev/null
+++ b/src/crewai/cli/run_flow.py
@@ -0,0 +1,23 @@
+import subprocess
+
+import click
+
+
+def run_flow() -> None:
+    """
+    Run the flow by running a command in the Poetry environment.
+    """
+    command = ["poetry", "run", "run_flow"]
+
+    try:
+        result = subprocess.run(command, capture_output=False, text=True, check=True)
+
+        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/src/crewai/cli/templates/crew/pyproject.toml b/src/crewai/cli/templates/crew/pyproject.toml
index 56944c83..6cb533fc 100644
--- a/src/crewai/cli/templates/crew/pyproject.toml
+++ b/src/crewai/cli/templates/crew/pyproject.toml
@@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
 
 [tool.poetry.dependencies]
 python = ">=3.10,<=3.13"
-crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" }
+crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" }
 
 
 [tool.poetry.scripts]
diff --git a/src/crewai/cli/templates/flow/main.py b/src/crewai/cli/templates/flow/main.py
index bda89065..38d2d873 100644
--- a/src/crewai/cli/templates/flow/main.py
+++ b/src/crewai/cli/templates/flow/main.py
@@ -22,8 +22,7 @@ class PoemFlow(Flow[PoemState]):
     def generate_poem(self):
         print("Generating poem")
         print(f"State before poem: {self.state}")
-        poem_crew = PoemCrew().crew()
-        result = poem_crew.kickoff(inputs={"sentence_count": self.state.sentence_count})
+        result = PoemCrew().crew().kickoff(inputs={"sentence_count": self.state.sentence_count})
         
         print("Poem generated", result.raw)
         self.state.poem = result.raw
@@ -38,16 +37,28 @@ class PoemFlow(Flow[PoemState]):
             f.write(self.state.poem)
         print(f"State after save_poem: {self.state}")
 
-async def run():
+async def run_flow():
     """
     Run the flow.
     """
     poem_flow = PoemFlow()
     await poem_flow.kickoff()
 
+async def plot_flow():
+    """
+    Plot the flow.
+    """
+    poem_flow = PoemFlow()
+    poem_flow.plot()
+
 
 def main():
-    asyncio.run(run())
+    asyncio.run(run_flow())
+
+
+def plot():
+    asyncio.run(plot_flow())
+
 
 
 if __name__ == "__main__":
diff --git a/src/crewai/cli/templates/flow/pyproject.toml b/src/crewai/cli/templates/flow/pyproject.toml
index 0e7083c7..9cebbc49 100644
--- a/src/crewai/cli/templates/flow/pyproject.toml
+++ b/src/crewai/cli/templates/flow/pyproject.toml
@@ -6,12 +6,13 @@ authors = ["Your Name <you@example.com>"]
 
 [tool.poetry.dependencies]
 python = ">=3.10,<=3.13"
-crewai = { extras = ["tools"], version = ">=0.55.2,<1.0.0" }
+crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" }
 asyncio = "*"
 
 [tool.poetry.scripts]
 {{folder_name}} = "{{folder_name}}.main:main"
-run_crew = "{{folder_name}}.main:main"
+run_flow = "{{folder_name}}.main:main"
+plot_flow = "{{folder_name}}.main:plot"
 
 [build-system]
 requires = ["poetry-core"]
diff --git a/src/crewai/cli/templates/pipeline/pyproject.toml b/src/crewai/cli/templates/pipeline/pyproject.toml
index e4aecb89..cafc79b8 100644
--- a/src/crewai/cli/templates/pipeline/pyproject.toml
+++ b/src/crewai/cli/templates/pipeline/pyproject.toml
@@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
 
 [tool.poetry.dependencies]
 python = ">=3.10,<=3.13"
-crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" }
+crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" }
 asyncio = "*"
 
 [tool.poetry.scripts]
diff --git a/src/crewai/cli/templates/pipeline_router/pyproject.toml b/src/crewai/cli/templates/pipeline_router/pyproject.toml
index 10810ddc..4e565551 100644
--- a/src/crewai/cli/templates/pipeline_router/pyproject.toml
+++ b/src/crewai/cli/templates/pipeline_router/pyproject.toml
@@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
 
 [tool.poetry.dependencies]
 python = ">=3.10,<=3.13"
-crewai = { extras = ["tools"], version = ">=0.66.0,<1.0.0" }
+crewai = { extras = ["tools"], version = ">=0.67.1,<1.0.0" }
 
 
 [tool.poetry.scripts]
diff --git a/src/crewai/cli/templates/tool/README.md b/src/crewai/cli/templates/tool/README.md
new file mode 100644
index 00000000..65cb0ffb
--- /dev/null
+++ b/src/crewai/cli/templates/tool/README.md
@@ -0,0 +1,48 @@
+# {{folder_name}}
+
+{{folder_name}} is a CrewAI Tool. This template is designed to help you create
+custom tools to power up your crews.
+
+## Installing
+
+Ensure you have Python >=3.10 <=3.13 installed on your system. This project
+uses [Poetry](https://python-poetry.org/) for dependency management and package
+handling, offering a seamless setup and execution experience.
+
+First, if you haven't already, install Poetry:
+
+```bash
+pip install poetry
+```
+
+Next, navigate to your project directory and install the dependencies with:
+
+```bash
+crewai install
+```
+
+## Publishing
+
+Collaborate by sharing tools within your organization, or publish them publicly
+to contribute with the community.
+
+```bash
+crewai tool publish {{tool_name}}
+```
+
+Others may install your tool in their crews running:
+
+```bash
+crewai tool install {{tool_name}}
+```
+
+## Support
+
+For support, questions, or feedback regarding the {{crew_name}} tool or CrewAI.
+
+- Visit our [documentation](https://docs.crewai.com)
+- Reach out to us through our [GitHub repository](https://github.com/joaomdmoura/crewai)
+- [Join our Discord](https://discord.com/invite/X4JWnZnxPb)
+- [Chat with our docs](https://chatg.pt/DWjSBZn)
+
+Let's create wonders together with the power and simplicity of crewAI.
diff --git a/src/crewai/cli/templates/tool/pyproject.toml b/src/crewai/cli/templates/tool/pyproject.toml
new file mode 100644
index 00000000..d02a858e
--- /dev/null
+++ b/src/crewai/cli/templates/tool/pyproject.toml
@@ -0,0 +1,14 @@
+[tool.poetry]
+name = "{{folder_name}}"
+version = "0.1.0"
+description = "Power up your crews with {{folder_name}}"
+authors = ["Your Name <you@example.com>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<=3.13"
+crewai = { extras = ["tools"], version = ">=0.64.0,<1.0.0" }
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py b/src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py b/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py
new file mode 100644
index 00000000..63c653a6
--- /dev/null
+++ b/src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py
@@ -0,0 +1,9 @@
+from crewai_tools import BaseTool
+
+class {{class_name}}(BaseTool):
+    name: str = "Name of my tool"
+    description: str = "What this tool does. It's vital for effective utilization."
+
+    def _run(self, argument: str) -> str:
+        # Your tool's logic here
+        return "Tool's result"
diff --git a/src/crewai/cli/tools/main.py b/src/crewai/cli/tools/main.py
index 8acbcedd..68c8f342 100644
--- a/src/crewai/cli/tools/main.py
+++ b/src/crewai/cli/tools/main.py
@@ -1,14 +1,18 @@
 import base64
+from pathlib import Path
 import click
 import os
 import subprocess
 import tempfile
 
 from crewai.cli.command import BaseCommand, PlusAPIMixin
+from crewai.cli import git
 from crewai.cli.utils import (
     get_project_name,
     get_project_description,
     get_project_version,
+    tree_copy,
+    tree_find_and_replace,
 )
 from rich.console import Console
 
@@ -24,7 +28,49 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
         BaseCommand.__init__(self)
         PlusAPIMixin.__init__(self, telemetry=self._telemetry)
 
+    def create(self, handle: str):
+        self._ensure_not_in_project()
+
+        folder_name = handle.replace(" ", "_").replace("-", "_").lower()
+        class_name = handle.replace("_", " ").replace("-", " ").title().replace(" ", "")
+
+        project_root = Path(folder_name)
+        if project_root.exists():
+            click.secho(f"Folder {folder_name} already exists.", fg="red")
+            raise SystemExit
+        else:
+            os.makedirs(project_root)
+
+        click.secho(f"Creating custom tool {folder_name}...", fg="green", bold=True)
+
+        template_dir = Path(__file__).parent.parent / "templates" / "tool"
+        tree_copy(template_dir, project_root)
+        tree_find_and_replace(project_root, "{{folder_name}}", folder_name)
+        tree_find_and_replace(project_root, "{{class_name}}", class_name)
+
+        old_directory = os.getcwd()
+        os.chdir(project_root)
+        try:
+            self.login()
+            subprocess.run(["git", "init"], check=True)
+            console.print(
+                f"[green]Created custom tool [bold]{folder_name}[/bold]. Run [bold]cd {project_root}[/bold] to start working.[/green]"
+            )
+        finally:
+            os.chdir(old_directory)
+
     def publish(self, is_public: bool):
+        if not git.Repository().is_synced():
+            console.print(
+                "[bold red]Failed to publish tool.[/bold red]\n"
+                "Local changes need to be resolved before publishing. Please do the following:\n"
+                "* [bold]Commit[/bold] your changes.\n"
+                "* [bold]Push[/bold] to sync with the remote.\n"
+                "* [bold]Pull[/bold] the latest changes from the remote.\n"
+                "\nOnce your repository is up-to-date, retry publishing the tool."
+            )
+            raise SystemExit()
+
         project_name = get_project_name(require=True)
         assert isinstance(project_name, str)
 
@@ -64,23 +110,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
             description=project_description,
             encoded_file=f"data:application/x-gzip;base64,{encoded_tarball}",
         )
-        if publish_response.status_code == 422:
-            console.print(
-                "[bold red]Failed to publish tool. Please fix the following errors:[/bold red]"
-            )
-            for field, messages in publish_response.json().items():
-                for message in messages:
-                    console.print(
-                        f"* [bold red]{field.capitalize()}[/bold red] {message}"
-                    )
 
-            raise SystemExit
-        elif publish_response.status_code != 200:
-            self._handle_plus_api_error(publish_response.json())
-            console.print(
-                "Failed to publish tool. Please try again later.", style="bold red"
-            )
-            raise SystemExit
+        self._validate_response(publish_response)
 
         published_handle = publish_response.json()["handle"]
         console.print(
@@ -103,15 +134,32 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
             )
             raise SystemExit
 
-        self._add_repository_to_poetry(get_response.json())
         self._add_package(get_response.json())
 
         console.print(f"Succesfully installed {handle}", style="bold green")
 
-    def _add_repository_to_poetry(self, tool_details):
-        repository_handle = f"crewai-{tool_details['repository']['handle']}"
-        repository_url = tool_details["repository"]["url"]
-        repository_credentials = tool_details["repository"]["credentials"]
+    def login(self):
+        login_response = self.plus_api_client.login_to_tool_repository()
+
+        if login_response.status_code != 200:
+            console.print(
+                "Failed to authenticate to the tool repository. Make sure you have the access to tools.",
+                style="bold red",
+            )
+            raise SystemExit
+
+        login_response_json = login_response.json()
+        for repository in login_response_json["repositories"]:
+            self._add_repository_to_poetry(
+                repository, login_response_json["credential"]
+            )
+
+        console.print(
+            "Succesfully authenticated to the tool repository.", style="bold green"
+        )
+
+    def _add_repository_to_poetry(self, repository, credentials):
+        repository_handle = f"crewai-{repository['handle']}"
 
         add_repository_command = [
             "poetry",
@@ -119,7 +167,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
             "add",
             "--priority=explicit",
             repository_handle,
-            repository_url,
+            repository["url"],
         ]
         add_repository_result = subprocess.run(
             add_repository_command, text=True, check=True
@@ -133,8 +181,8 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
             "poetry",
             "config",
             f"http-basic.{repository_handle}",
-            repository_credentials,
-            '""',
+            credentials["username"],
+            credentials["password"],
         ]
         add_repository_credentials_result = subprocess.run(
             add_repository_credentials_command,
@@ -166,3 +214,16 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
         if add_package_result.stderr:
             click.echo(add_package_result.stderr, err=True)
             raise SystemExit
+
+    def _ensure_not_in_project(self):
+        if os.path.isfile("./pyproject.toml"):
+            console.print(
+                "[bold red]Oops! It looks like you're inside a project.[/bold red]"
+            )
+            console.print(
+                "You can't create a new tool while inside an existing project."
+            )
+            console.print(
+                "[bold yellow]Tip:[/bold yellow] Navigate to a different directory and try again."
+            )
+            raise SystemExit
diff --git a/src/crewai/cli/utils.py b/src/crewai/cli/utils.py
index 58aa154d..ba553f03 100644
--- a/src/crewai/cli/utils.py
+++ b/src/crewai/cli/utils.py
@@ -1,7 +1,8 @@
+import os
+import shutil
 import click
-import re
-import subprocess
 import sys
+import importlib.metadata
 
 from crewai.cli.authentication.utils import TokenManager
 from functools import reduce
@@ -58,38 +59,6 @@ def parse_toml(content):
         return simple_toml_parser(content)
 
 
-def get_git_remote_url() -> str | None:
-    """Get the Git repository's remote URL."""
-    try:
-        # Run the git remote -v command
-        result = subprocess.run(
-            ["git", "remote", "-v"], capture_output=True, text=True, check=True
-        )
-
-        # Get the output
-        output = result.stdout
-
-        # Parse the output to find the origin URL
-        matches = re.findall(r"origin\s+(.*?)\s+\(fetch\)", output)
-
-        if matches:
-            return matches[0]  # Return the first match (origin URL)
-        else:
-            console.print("No origin remote found.", style="bold red")
-
-    except subprocess.CalledProcessError as e:
-        console.print(
-            f"Error running trying to fetch the Git Repository: {e}", style="bold red"
-        )
-    except FileNotFoundError:
-        console.print(
-            "Git command not found. Make sure Git is installed and in your PATH.",
-            style="bold red",
-        )
-
-    return None
-
-
 def get_project_name(
     pyproject_path: str = "pyproject.toml", require: bool = False
 ) -> str | None:
@@ -162,29 +131,9 @@ def _get_nested_value(data: Dict[str, Any], keys: List[str]) -> Any:
     return reduce(dict.__getitem__, keys, data)
 
 
-def get_crewai_version(poetry_lock_path: str = "poetry.lock") -> str:
-    """Get the version number of crewai from the poetry.lock file."""
-    try:
-        with open(poetry_lock_path, "r") as f:
-            lock_content = f.read()
-
-        match = re.search(
-            r'\[\[package\]\]\s*name\s*=\s*"crewai"\s*version\s*=\s*"([^"]+)"',
-            lock_content,
-            re.DOTALL,
-        )
-        if match:
-            return match.group(1)
-        else:
-            print("crewai package not found in poetry.lock")
-            return "no-version-found"
-
-    except FileNotFoundError:
-        print(f"Error: {poetry_lock_path} not found.")
-    except Exception as e:
-        print(f"Error reading the poetry.lock file: {e}")
-
-    return "no-version-found"
+def get_crewai_version() -> str:
+    """Get the version number of CrewAI running the CLI"""
+    return importlib.metadata.version("crewai")
 
 
 def fetch_and_json_env_file(env_file_path: str = ".env") -> dict:
@@ -217,3 +166,40 @@ def get_auth_token() -> str:
     if not access_token:
         raise Exception()
     return access_token
+
+
+def tree_copy(source, destination):
+    """Copies the entire directory structure from the source to the destination."""
+    for item in os.listdir(source):
+        source_item = os.path.join(source, item)
+        destination_item = os.path.join(destination, item)
+        if os.path.isdir(source_item):
+            shutil.copytree(source_item, destination_item)
+        else:
+            shutil.copy2(source_item, destination_item)
+
+
+def tree_find_and_replace(directory, find, replace):
+    """Recursively searches through a directory, replacing a target string in
+    both file contents and filenames with a specified replacement string.
+    """
+    for path, dirs, files in os.walk(os.path.abspath(directory), topdown=False):
+        for filename in files:
+            filepath = os.path.join(path, filename)
+
+            with open(filepath, "r") as file:
+                contents = file.read()
+            with open(filepath, "w") as file:
+                file.write(contents.replace(find, replace))
+
+            if find in filename:
+                new_filename = filename.replace(find, replace)
+                new_filepath = os.path.join(path, new_filename)
+                os.rename(filepath, new_filepath)
+
+        for dirname in dirs:
+            if find in dirname:
+                new_dirname = dirname.replace(find, replace)
+                new_dirpath = os.path.join(path, new_dirname)
+                old_dirpath = os.path.join(path, dirname)
+                os.rename(old_dirpath, new_dirpath)
diff --git a/src/crewai/flow/config.py b/src/crewai/flow/config.py
new file mode 100644
index 00000000..ddaddc7a
--- /dev/null
+++ b/src/crewai/flow/config.py
@@ -0,0 +1,46 @@
+DARK_GRAY = "#333333"
+CREWAI_ORANGE = "#FF5A50"
+GRAY = "#666666"
+WHITE = "#FFFFFF"
+
+COLORS = {
+    "bg": WHITE,
+    "start": CREWAI_ORANGE,
+    "method": DARK_GRAY,
+    "router": DARK_GRAY,
+    "router_border": CREWAI_ORANGE,
+    "edge": GRAY,
+    "router_edge": CREWAI_ORANGE,
+    "text": WHITE,
+}
+
+NODE_STYLES = {
+    "start": {
+        "color": COLORS["start"],
+        "shape": "box",
+        "font": {"color": COLORS["text"]},
+        "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
+    },
+    "method": {
+        "color": COLORS["method"],
+        "shape": "box",
+        "font": {"color": COLORS["text"]},
+        "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
+    },
+    "router": {
+        "color": {
+            "background": COLORS["router"],
+            "border": COLORS["router_border"],
+            "highlight": {
+                "border": COLORS["router_border"],
+                "background": COLORS["router"],
+            },
+        },
+        "shape": "box",
+        "font": {"color": COLORS["text"]},
+        "borderWidth": 3,
+        "borderWidthSelected": 4,
+        "shapeProperties": {"borderDashes": [5, 5]},
+        "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
+    },
+}
diff --git a/src/crewai/flow/flow.py b/src/crewai/flow/flow.py
index 4dd76113..f60e5c1e 100644
--- a/src/crewai/flow/flow.py
+++ b/src/crewai/flow/flow.py
@@ -1,12 +1,14 @@
 # flow.py
 
+# flow.py
+
 import asyncio
 import inspect
 from typing import Any, Callable, Dict, Generic, List, Set, Type, TypeVar, Union
 
 from pydantic import BaseModel
 
-from crewai.flow.flow_visualizer import visualize_flow
+from crewai.flow.flow_visualizer import plot_flow
 
 T = TypeVar("T", bound=Union[BaseModel, Dict[str, Any]])
 
@@ -268,5 +270,5 @@ class Flow(Generic[T], metaclass=FlowMeta):
 
             traceback.print_exc()
 
-    def visualize(self, filename: str = "crewai_flow_graph"):
-        visualize_flow(self, filename)
+    def plot(self, filename: str = "crewai_flow_graph"):
+        plot_flow(self, filename)
diff --git a/src/crewai/flow/flow_visualizer.py b/src/crewai/flow/flow_visualizer.py
index 8b00d882..822f192b 100644
--- a/src/crewai/flow/flow_visualizer.py
+++ b/src/crewai/flow/flow_visualizer.py
@@ -1,63 +1,27 @@
 # flow_visualizer.py
 
-import base64
 import os
-import re
 
 from pyvis.network import Network
 
-DARK_GRAY = "#333333"
-CREWAI_ORANGE = "#FF5A50"
-GRAY = "#666666"
-WHITE = "#FFFFFF"
+from crewai.flow.config import COLORS, NODE_STYLES
+from crewai.flow.html_template_handler import HTMLTemplateHandler
+from crewai.flow.legend_generator import generate_legend_items_html, get_legend_items
+from crewai.flow.utils import calculate_node_levels
+from crewai.flow.visualization_utils import (
+    add_edges,
+    add_nodes_to_network,
+    compute_positions,
+)
 
 
-class FlowVisualizer:
+class FlowPlot:
     def __init__(self, flow):
         self.flow = flow
-        self.colors = {
-            "bg": WHITE,
-            "start": CREWAI_ORANGE,
-            "method": DARK_GRAY,
-            "router": DARK_GRAY,
-            "router_border": CREWAI_ORANGE,
-            "edge": GRAY,
-            "router_edge": CREWAI_ORANGE,
-            "text": WHITE,
-        }
-        self.node_styles = {
-            "start": {
-                "color": self.colors["start"],
-                "shape": "box",
-                "font": {"color": self.colors["text"]},
-                "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
-            },
-            "method": {
-                "color": self.colors["method"],
-                "shape": "box",
-                "font": {"color": self.colors["text"]},
-                "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
-            },
-            "router": {
-                "color": {
-                    "background": self.colors["router"],
-                    "border": self.colors["router_border"],
-                    "highlight": {
-                        "border": self.colors["router_border"],
-                        "background": self.colors["router"],
-                    },
-                },
-                "shape": "box",
-                "font": {"color": self.colors["text"]},
-                "borderWidth": 3,
-                "borderWidthSelected": 4,
-                "shapeProperties": {"borderDashes": [5, 5]},
-                "margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
-            },
-        }
+        self.colors = COLORS
+        self.node_styles = NODE_STYLES
 
-    # TODO: DROP LIB FOLDER POST GENERATION
-    def visualize(self, filename):
+    def plot(self, filename):
         net = Network(
             directed=True,
             height="750px",
@@ -67,172 +31,16 @@ class FlowVisualizer:
         )
 
         # Calculate levels for nodes
-        node_levels = self._calculate_node_levels()
-
-        # Assign positions to nodes based on levels
-        y_spacing = 150
-        x_spacing = 150
-        level_nodes = {}
-
-        # Store node positions for edge calculations
-        node_positions = {}
-
-        for method_name, level in node_levels.items():
-            level_nodes.setdefault(level, []).append(method_name)
+        node_levels = calculate_node_levels(self.flow)
 
         # Compute positions
-        for level, nodes in level_nodes.items():
-            x_offset = -(len(nodes) - 1) * x_spacing / 2  # Center nodes horizontally
-            for i, method_name in enumerate(nodes):
-                x = x_offset + i * x_spacing
-                y = level * y_spacing
-                node_positions[method_name] = (x, y)
-
-                method = self.flow._methods.get(method_name)
-                if hasattr(method, "__is_start_method__"):
-                    node_style = self.node_styles["start"]
-                elif hasattr(method, "__is_router__"):
-                    node_style = self.node_styles["router"]
-                else:
-                    node_style = self.node_styles["method"]
-
-                net.add_node(
-                    method_name,
-                    label=method_name,
-                    x=x,
-                    y=y,
-                    fixed=True,
-                    physics=False,
-                    **node_style,
-                )
-
-        ancestors = self._build_ancestor_dict()
-        parent_children = self._build_parent_children_dict()
-
-        # Add edges
-        for method_name in self.flow._listeners:
-            condition_type, trigger_methods = self.flow._listeners[method_name]
-            is_and_condition = condition_type == "AND"
-
-            for trigger in trigger_methods:
-                if (
-                    trigger in self.flow._methods
-                    or trigger in self.flow._routers.values()
-                ):
-                    is_router_edge = any(
-                        trigger in paths for paths in self.flow._router_paths.values()
-                    )
-                    edge_color = (
-                        self.colors["router_edge"]
-                        if is_router_edge
-                        else self.colors["edge"]
-                    )
-
-                    # Determine if this edge forms a cycle
-                    is_cycle_edge = self._is_ancestor(trigger, method_name, ancestors)
-
-                    # Determine if parent has multiple children
-                    parent_has_multiple_children = (
-                        len(parent_children.get(trigger, [])) > 1
-                    )
-
-                    # Edge curvature logic
-                    needs_curvature = is_cycle_edge or parent_has_multiple_children
-
-                    if needs_curvature:
-                        # Get node positions
-                        source_pos = node_positions.get(trigger)
-                        target_pos = node_positions.get(method_name)
-
-                        if source_pos and target_pos:
-                            dx = target_pos[0] - source_pos[0]
-
-                            if dx <= 0:
-                                # Child is left or directly below
-                                smooth_type = "curvedCCW"  # Curve left and down
-                            else:
-                                # Child is to the right
-                                smooth_type = "curvedCW"  # Curve right and down
-
-                            index = self._get_child_index(
-                                trigger, method_name, parent_children
-                            )
-                            edge_smooth = {
-                                "type": smooth_type,
-                                "roundness": 0.2 + (0.1 * index),
-                            }
-                        else:
-                            # Fallback curvature
-                            edge_smooth = {"type": "cubicBezier"}
-                    else:
-                        edge_smooth = False  # Draw straight line
+        node_positions = compute_positions(self.flow, node_levels)
 
-                    edge_style = {
-                        "color": edge_color,
-                        "width": 2,
-                        "arrows": "to",
-                        "dashes": True if is_router_edge or is_and_condition else False,
-                        "smooth": edge_smooth,
-                    }
+        # Add nodes to the network
+        add_nodes_to_network(net, self.flow, node_positions, self.node_styles)
 
-                    net.add_edge(trigger, method_name, **edge_style)
-
-        # Add edges from router methods to their possible paths
-        for router_method_name, paths in self.flow._router_paths.items():
-            for path in paths:
-                for listener_name, (
-                    condition_type,
-                    trigger_methods,
-                ) in self.flow._listeners.items():
-                    if path in trigger_methods:
-                        is_cycle_edge = self._is_ancestor(
-                            trigger, method_name, ancestors
-                        )
-
-                        # Determine if parent has multiple children
-                        parent_has_multiple_children = (
-                            len(parent_children.get(router_method_name, [])) > 1
-                        )
-
-                        # Edge curvature logic
-                        needs_curvature = is_cycle_edge or parent_has_multiple_children
-
-                        if needs_curvature:
-                            # Get node positions
-                            source_pos = node_positions.get(router_method_name)
-                            target_pos = node_positions.get(listener_name)
-
-                            if source_pos and target_pos:
-                                dx = target_pos[0] - source_pos[0]
-
-                                if dx <= 0:
-                                    # Child is left or directly below
-                                    smooth_type = "curvedCCW"  # Curve left and down
-                                else:
-                                    # Child is to the right
-                                    smooth_type = "curvedCW"  # Curve right and down
-
-                                index = self._get_child_index(
-                                    router_method_name, listener_name, parent_children
-                                )
-                                edge_smooth = {
-                                    "type": smooth_type,
-                                    "roundness": 0.2 + (0.1 * index),
-                                }
-                            else:
-                                # Fallback curvature
-                                edge_smooth = {"type": "cubicBezier"}
-                        else:
-                            edge_smooth = False  # Straight line
-
-                        edge_style = {
-                            "color": self.colors["router_edge"],
-                            "width": 2,
-                            "arrows": "to",
-                            "dashes": True,
-                            "smooth": edge_smooth,
-                        }
-                        net.add_edge(router_method_name, listener_name, **edge_style)
+        # Add edges to the network
+        add_edges(net, self.flow, node_positions, self.colors)
 
         # Set options to disable physics
         net.set_options(
@@ -246,229 +54,46 @@ class FlowVisualizer:
         )
 
         network_html = net.generate_html()
-
-        # Extract just the body content from the generated HTML
-        match = re.search("<body.*?>(.*?)</body>", network_html, re.DOTALL)
-        if match:
-            network_body = match.group(1)
-        else:
-            network_body = ""
-
-        # Read the custom template
-        current_dir = os.path.dirname(__file__)
-        template_path = os.path.join(
-            current_dir, "assets", "crewai_flow_visual_template.html"
-        )
-        with open(template_path, "r", encoding="utf-8") as f:
-            html_template = f.read()
-
-        # Generate the legend items HTML
-        legend_items = [
-            {"label": "Start Method", "color": self.colors["start"]},
-            {"label": "Method", "color": self.colors["method"]},
-            {
-                "label": "Router",
-                "color": self.colors["router"],
-                "border": self.colors["router_border"],
-                "dashed": True,
-            },
-            {"label": "Trigger", "color": self.colors["edge"], "dashed": False},
-            {"label": "AND Trigger", "color": self.colors["edge"], "dashed": True},
-            {
-                "label": "Router Trigger",
-                "color": self.colors["router_edge"],
-                "dashed": True,
-            },
-        ]
-
-        legend_items_html = ""
-        for item in legend_items:
-            if "border" in item:
-                legend_items_html += f"""
-                <div class="legend-item">
-                <div class="legend-color-box" style="background-color: {item['color']}; border: 2px dashed {item['border']};"></div>
-                <div>{item['label']}</div>
-                </div>
-                """
-            elif item.get("dashed") is not None:
-                style = "dashed" if item["dashed"] else "solid"
-                legend_items_html += f"""
-                <div class="legend-item">
-                <div class="legend-{style}" style="border-bottom: 2px {style} {item['color']};"></div>
-                <div>{item['label']}</div>
-                </div>
-                """
-            else:
-                legend_items_html += f"""
-                <div class="legend-item">
-                <div class="legend-color-box" style="background-color: {item['color']};"></div>
-                <div>{item['label']}</div>
-                </div>
-                """
-
-        # Read the logo file and encode it
-        logo_path = os.path.join(current_dir, "assets", "crewai_logo.svg")
-        with open(logo_path, "rb") as logo_file:
-            logo_svg_data = logo_file.read()
-            logo_svg_base64 = base64.b64encode(logo_svg_data).decode("utf-8")
-
-        # Replace placeholders in the template
-        final_html_content = html_template.replace("{{ title }}", "Flow Graph")
-        final_html_content = final_html_content.replace(
-            "{{ network_content }}", network_body
-        )
-        final_html_content = final_html_content.replace(
-            "{{ logo_svg_base64 }}", logo_svg_base64
-        )
-        final_html_content = final_html_content.replace(
-            "<!-- LEGEND_ITEMS_PLACEHOLDER -->", legend_items_html
-        )
+        final_html_content = self._generate_final_html(network_html)
 
         # Save the final HTML content to the file
         with open(f"{filename}.html", "w", encoding="utf-8") as f:
             f.write(final_html_content)
         print(f"Graph saved as {filename}.html")
 
-    def _calculate_node_levels(self):
-        levels = {}
-        queue = []
-        visited = set()
-        pending_and_listeners = {}
-
-        # Make all start methods at level 0
-        for method_name, method in self.flow._methods.items():
-            if hasattr(method, "__is_start_method__"):
-                levels[method_name] = 0
-                queue.append(method_name)
-
-        # Breadth-first traversal to assign levels
-        while queue:
-            current = queue.pop(0)
-            current_level = levels[current]
-            visited.add(current)
-
-            for listener_name, (
-                condition_type,
-                trigger_methods,
-            ) in self.flow._listeners.items():
-                if condition_type == "OR":
-                    if current in trigger_methods:
-                        if (
-                            listener_name not in levels
-                            or levels[listener_name] > current_level + 1
-                        ):
-                            levels[listener_name] = current_level + 1
-                            if listener_name not in visited:
-                                queue.append(listener_name)
-                elif condition_type == "AND":
-                    if listener_name not in pending_and_listeners:
-                        pending_and_listeners[listener_name] = set()
-                    if current in trigger_methods:
-                        pending_and_listeners[listener_name].add(current)
-                    if set(trigger_methods) == pending_and_listeners[listener_name]:
-                        if (
-                            listener_name not in levels
-                            or levels[listener_name] > current_level + 1
-                        ):
-                            levels[listener_name] = current_level + 1
-                            if listener_name not in visited:
-                                queue.append(listener_name)
-
-            # Handle router connections
-            if current in self.flow._routers.values():
-                router_method_name = current
-                paths = self.flow._router_paths.get(router_method_name, [])
-                for path in paths:
-                    for listener_name, (
-                        condition_type,
-                        trigger_methods,
-                    ) in self.flow._listeners.items():
-                        if path in trigger_methods:
-                            if (
-                                listener_name not in levels
-                                or levels[listener_name] > current_level + 1
-                            ):
-                                levels[listener_name] = current_level + 1
-                                if listener_name not in visited:
-                                    queue.append(listener_name)
-        return levels
-
-    def _count_outgoing_edges(self):
-        counts = {}
-        for method_name in self.flow._methods:
-            counts[method_name] = 0
-        for method_name in self.flow._listeners:
-            _, trigger_methods = self.flow._listeners[method_name]
-            for trigger in trigger_methods:
-                if trigger in self.flow._methods:
-                    counts[trigger] += 1
-        return counts
+        self._cleanup_pyvis_lib()
 
-    def _build_ancestor_dict(self):
-        ancestors = {node: set() for node in self.flow._methods}
-        visited = set()
-        for node in self.flow._methods:
-            if node not in visited:
-                self._dfs_ancestors(node, ancestors, visited)
-
-        return ancestors
-
-    def _dfs_ancestors(self, node, ancestors, visited):
-        if node in visited:
-            return
-        visited.add(node)
-
-        # Handle regular listeners
-        for listener_name, (_, trigger_methods) in self.flow._listeners.items():
-            if node in trigger_methods:
-                ancestors[listener_name].add(node)
-                ancestors[listener_name].update(ancestors[node])
-                self._dfs_ancestors(listener_name, ancestors, visited)
-
-        # Handle router methods separately
-        if node in self.flow._routers.values():
-            router_method_name = node
-            paths = self.flow._router_paths.get(router_method_name, [])
-            for path in paths:
-                for listener_name, (_, trigger_methods) in self.flow._listeners.items():
-                    if path in trigger_methods:
-                        # Only propagate the ancestors of the router method, not the router method itself
-                        ancestors[listener_name].update(ancestors[node])
-                        self._dfs_ancestors(listener_name, ancestors, visited)
-
-    def _is_ancestor(self, node, ancestor_candidate, ancestors):
-        return ancestor_candidate in ancestors.get(node, set())
-
-    def _build_parent_children_dict(self):
-        parent_children = {}
+    def _generate_final_html(self, network_html):
+        # Extract just the body content from the generated HTML
+        current_dir = os.path.dirname(__file__)
+        template_path = os.path.join(
+            current_dir, "assets", "crewai_flow_visual_template.html"
+        )
+        logo_path = os.path.join(current_dir, "assets", "crewai_logo.svg")
 
-        # Map listeners to their trigger methods
-        for listener_name, (_, trigger_methods) in self.flow._listeners.items():
-            for trigger in trigger_methods:
-                if trigger not in parent_children:
-                    parent_children[trigger] = []
-                if listener_name not in parent_children[trigger]:
-                    parent_children[trigger].append(listener_name)
+        html_handler = HTMLTemplateHandler(template_path, logo_path)
+        network_body = html_handler.extract_body_content(network_html)
 
-        # Map router methods to their paths and to listeners
-        for router_method_name, paths in self.flow._router_paths.items():
-            for path in paths:
-                # Map router method to listeners of each path
-                for listener_name, (_, trigger_methods) in self.flow._listeners.items():
-                    if path in trigger_methods:
-                        if router_method_name not in parent_children:
-                            parent_children[router_method_name] = []
-                        if listener_name not in parent_children[router_method_name]:
-                            parent_children[router_method_name].append(listener_name)
+        # Generate the legend items HTML
+        legend_items = get_legend_items(self.colors)
+        legend_items_html = generate_legend_items_html(legend_items)
+        final_html_content = html_handler.generate_final_html(
+            network_body, legend_items_html
+        )
+        return final_html_content
 
-        return parent_children
+    def _cleanup_pyvis_lib(self):
+        # Clean up the generated lib folder
+        lib_folder = os.path.join(os.getcwd(), "lib")
+        try:
+            if os.path.exists(lib_folder) and os.path.isdir(lib_folder):
+                import shutil
 
-    def _get_child_index(self, parent, child, parent_children):
-        children = parent_children.get(parent, [])
-        children.sort()
-        return children.index(child)
+                shutil.rmtree(lib_folder)
+        except Exception as e:
+            print(f"Error cleaning up {lib_folder}: {e}")
 
 
-def visualize_flow(flow, filename="flow_graph"):
-    visualizer = FlowVisualizer(flow)
-    visualizer.visualize(filename)
+def plot_flow(flow, filename="flow_graph"):
+    visualizer = FlowPlot(flow)
+    visualizer.plot(filename)
diff --git a/src/crewai/flow/html_template_handler.py b/src/crewai/flow/html_template_handler.py
new file mode 100644
index 00000000..8a88da42
--- /dev/null
+++ b/src/crewai/flow/html_template_handler.py
@@ -0,0 +1,66 @@
+import base64
+import os
+import re
+
+
+class HTMLTemplateHandler:
+    def __init__(self, template_path, logo_path):
+        self.template_path = template_path
+        self.logo_path = logo_path
+
+    def read_template(self):
+        with open(self.template_path, "r", encoding="utf-8") as f:
+            return f.read()
+
+    def encode_logo(self):
+        with open(self.logo_path, "rb") as logo_file:
+            logo_svg_data = logo_file.read()
+            return base64.b64encode(logo_svg_data).decode("utf-8")
+
+    def extract_body_content(self, html):
+        match = re.search("<body.*?>(.*?)</body>", html, re.DOTALL)
+        return match.group(1) if match else ""
+
+    def generate_legend_items_html(self, legend_items):
+        legend_items_html = ""
+        for item in legend_items:
+            if "border" in item:
+                legend_items_html += f"""
+                <div class="legend-item">
+                <div class="legend-color-box" style="background-color: {item['color']}; border: 2px dashed {item['border']};"></div>
+                <div>{item['label']}</div>
+                </div>
+                """
+            elif item.get("dashed") is not None:
+                style = "dashed" if item["dashed"] else "solid"
+                legend_items_html += f"""
+                <div class="legend-item">
+                <div class="legend-{style}" style="border-bottom: 2px {style} {item['color']};"></div>
+                <div>{item['label']}</div>
+                </div>
+                """
+            else:
+                legend_items_html += f"""
+                <div class="legend-item">
+                <div class="legend-color-box" style="background-color: {item['color']};"></div>
+                <div>{item['label']}</div>
+                </div>
+                """
+        return legend_items_html
+
+    def generate_final_html(self, network_body, legend_items_html, title="Flow Graph"):
+        html_template = self.read_template()
+        logo_svg_base64 = self.encode_logo()
+
+        final_html_content = html_template.replace("{{ title }}", title)
+        final_html_content = final_html_content.replace(
+            "{{ network_content }}", network_body
+        )
+        final_html_content = final_html_content.replace(
+            "{{ logo_svg_base64 }}", logo_svg_base64
+        )
+        final_html_content = final_html_content.replace(
+            "<!-- LEGEND_ITEMS_PLACEHOLDER -->", legend_items_html
+        )
+
+        return final_html_content
diff --git a/src/crewai/flow/legend_generator.py b/src/crewai/flow/legend_generator.py
new file mode 100644
index 00000000..83d9b97a
--- /dev/null
+++ b/src/crewai/flow/legend_generator.py
@@ -0,0 +1,46 @@
+def get_legend_items(colors):
+    return [
+        {"label": "Start Method", "color": colors["start"]},
+        {"label": "Method", "color": colors["method"]},
+        {
+            "label": "Router",
+            "color": colors["router"],
+            "border": colors["router_border"],
+            "dashed": True,
+        },
+        {"label": "Trigger", "color": colors["edge"], "dashed": False},
+        {"label": "AND Trigger", "color": colors["edge"], "dashed": True},
+        {
+            "label": "Router Trigger",
+            "color": colors["router_edge"],
+            "dashed": True,
+        },
+    ]
+
+
+def generate_legend_items_html(legend_items):
+    legend_items_html = ""
+    for item in legend_items:
+        if "border" in item:
+            legend_items_html += f"""
+            <div class="legend-item">
+            <div class="legend-color-box" style="background-color: {item['color']}; border: 2px dashed {item['border']};"></div>
+            <div>{item['label']}</div>
+            </div>
+            """
+        elif item.get("dashed") is not None:
+            style = "dashed" if item["dashed"] else "solid"
+            legend_items_html += f"""
+            <div class="legend-item">
+            <div class="legend-{style}" style="border-bottom: 2px {style} {item['color']};"></div>
+            <div>{item['label']}</div>
+            </div>
+            """
+        else:
+            legend_items_html += f"""
+            <div class="legend-item">
+            <div class="legend-color-box" style="background-color: {item['color']};"></div>
+            <div>{item['label']}</div>
+            </div>
+            """
+    return legend_items_html
diff --git a/src/crewai/flow/utils.py b/src/crewai/flow/utils.py
new file mode 100644
index 00000000..f2dbfb7f
--- /dev/null
+++ b/src/crewai/flow/utils.py
@@ -0,0 +1,143 @@
+def calculate_node_levels(flow):
+    levels = {}
+    queue = []
+    visited = set()
+    pending_and_listeners = {}
+
+    # Make all start methods at level 0
+    for method_name, method in flow._methods.items():
+        if hasattr(method, "__is_start_method__"):
+            levels[method_name] = 0
+            queue.append(method_name)
+
+    # Breadth-first traversal to assign levels
+    while queue:
+        current = queue.pop(0)
+        current_level = levels[current]
+        visited.add(current)
+
+        for listener_name, (
+            condition_type,
+            trigger_methods,
+        ) in flow._listeners.items():
+            if condition_type == "OR":
+                if current in trigger_methods:
+                    if (
+                        listener_name not in levels
+                        or levels[listener_name] > current_level + 1
+                    ):
+                        levels[listener_name] = current_level + 1
+                        if listener_name not in visited:
+                            queue.append(listener_name)
+            elif condition_type == "AND":
+                if listener_name not in pending_and_listeners:
+                    pending_and_listeners[listener_name] = set()
+                if current in trigger_methods:
+                    pending_and_listeners[listener_name].add(current)
+                if set(trigger_methods) == pending_and_listeners[listener_name]:
+                    if (
+                        listener_name not in levels
+                        or levels[listener_name] > current_level + 1
+                    ):
+                        levels[listener_name] = current_level + 1
+                        if listener_name not in visited:
+                            queue.append(listener_name)
+
+        # Handle router connections
+        if current in flow._routers.values():
+            router_method_name = current
+            paths = flow._router_paths.get(router_method_name, [])
+            for path in paths:
+                for listener_name, (
+                    condition_type,
+                    trigger_methods,
+                ) in flow._listeners.items():
+                    if path in trigger_methods:
+                        if (
+                            listener_name not in levels
+                            or levels[listener_name] > current_level + 1
+                        ):
+                            levels[listener_name] = current_level + 1
+                            if listener_name not in visited:
+                                queue.append(listener_name)
+    return levels
+
+
+def count_outgoing_edges(flow):
+    counts = {}
+    for method_name in flow._methods:
+        counts[method_name] = 0
+    for method_name in flow._listeners:
+        _, trigger_methods = flow._listeners[method_name]
+        for trigger in trigger_methods:
+            if trigger in flow._methods:
+                counts[trigger] += 1
+    return counts
+
+
+def build_ancestor_dict(flow):
+    ancestors = {node: set() for node in flow._methods}
+    visited = set()
+    for node in flow._methods:
+        if node not in visited:
+            dfs_ancestors(node, ancestors, visited, flow)
+    return ancestors
+
+
+def dfs_ancestors(node, ancestors, visited, flow):
+    if node in visited:
+        return
+    visited.add(node)
+
+    # Handle regular listeners
+    for listener_name, (_, trigger_methods) in flow._listeners.items():
+        if node in trigger_methods:
+            ancestors[listener_name].add(node)
+            ancestors[listener_name].update(ancestors[node])
+            dfs_ancestors(listener_name, ancestors, visited, flow)
+
+    # Handle router methods separately
+    if node in flow._routers.values():
+        router_method_name = node
+        paths = flow._router_paths.get(router_method_name, [])
+        for path in paths:
+            for listener_name, (_, trigger_methods) in flow._listeners.items():
+                if path in trigger_methods:
+                    # Only propagate the ancestors of the router method, not the router method itself
+                    ancestors[listener_name].update(ancestors[node])
+                    dfs_ancestors(listener_name, ancestors, visited, flow)
+
+
+def is_ancestor(node, ancestor_candidate, ancestors):
+    return ancestor_candidate in ancestors.get(node, set())
+
+
+def build_parent_children_dict(flow):
+    parent_children = {}
+
+    # Map listeners to their trigger methods
+    for listener_name, (_, trigger_methods) in flow._listeners.items():
+        for trigger in trigger_methods:
+            if trigger not in parent_children:
+                parent_children[trigger] = []
+            if listener_name not in parent_children[trigger]:
+                parent_children[trigger].append(listener_name)
+
+    # Map router methods to their paths and to listeners
+    for router_method_name, paths in flow._router_paths.items():
+        for path in paths:
+            # Map router method to listeners of each path
+            for listener_name, (_, trigger_methods) in flow._listeners.items():
+                if path in trigger_methods:
+                    if router_method_name not in parent_children:
+                        parent_children[router_method_name] = []
+                    if listener_name not in parent_children[router_method_name]:
+                        parent_children[router_method_name].append(listener_name)
+
+    return parent_children
+
+
+def get_child_index(parent, child, parent_children):
+    children = parent_children.get(parent, [])
+    children.sort()
+    return children.index(child)
diff --git a/src/crewai/flow/visualization_utils.py b/src/crewai/flow/visualization_utils.py
new file mode 100644
index 00000000..ba2ba5f1
--- /dev/null
+++ b/src/crewai/flow/visualization_utils.py
@@ -0,0 +1,132 @@
+from .utils import (
+    build_ancestor_dict,
+    build_parent_children_dict,
+    get_child_index,
+    is_ancestor,
+)
+
+
+def compute_positions(flow, node_levels, y_spacing=150, x_spacing=150):
+    level_nodes = {}
+    node_positions = {}
+
+    for method_name, level in node_levels.items():
+        level_nodes.setdefault(level, []).append(method_name)
+
+    for level, nodes in level_nodes.items():
+        x_offset = -(len(nodes) - 1) * x_spacing / 2  # Center nodes horizontally
+        for i, method_name in enumerate(nodes):
+            x = x_offset + i * x_spacing
+            y = level * y_spacing
+            node_positions[method_name] = (x, y)
+
+    return node_positions
+
+
+def add_edges(net, flow, node_positions, colors):
+    ancestors = build_ancestor_dict(flow)
+    parent_children = build_parent_children_dict(flow)
+
+    for method_name in flow._listeners:
+        condition_type, trigger_methods = flow._listeners[method_name]
+        is_and_condition = condition_type == "AND"
+
+        for trigger in trigger_methods:
+            if trigger in flow._methods or trigger in flow._routers.values():
+                is_router_edge = any(
+                    trigger in paths for paths in flow._router_paths.values()
+                )
+                edge_color = colors["router_edge"] if is_router_edge else colors["edge"]
+
+                is_cycle_edge = is_ancestor(trigger, method_name, ancestors)
+                parent_has_multiple_children = len(parent_children.get(trigger, [])) > 1
+                needs_curvature = is_cycle_edge or parent_has_multiple_children
+
+                if needs_curvature:
+                    source_pos = node_positions.get(trigger)
+                    target_pos = node_positions.get(method_name)
+
+                    if source_pos and target_pos:
+                        dx = target_pos[0] - source_pos[0]
+                        smooth_type = "curvedCCW" if dx <= 0 else "curvedCW"
+                        index = get_child_index(trigger, method_name, parent_children)
+                        edge_smooth = {
+                            "type": smooth_type,
+                            "roundness": 0.2 + (0.1 * index),
+                        }
+                    else:
+                        edge_smooth = {"type": "cubicBezier"}
+                else:
+                    edge_smooth = False
+
+                edge_style = {
+                    "color": edge_color,
+                    "width": 2,
+                    "arrows": "to",
+                    "dashes": True if is_router_edge or is_and_condition else False,
+                    "smooth": edge_smooth,
+                }
+
+                net.add_edge(trigger, method_name, **edge_style)
+
+    for router_method_name, paths in flow._router_paths.items():
+        for path in paths:
+            for listener_name, (
+                condition_type,
+                trigger_methods,
+            ) in flow._listeners.items():
+                if path in trigger_methods:
+                    is_cycle_edge = is_ancestor(trigger, method_name, ancestors)
+                    parent_has_multiple_children = (
+                        len(parent_children.get(router_method_name, [])) > 1
+                    )
+                    needs_curvature = is_cycle_edge or parent_has_multiple_children
+
+                    if needs_curvature:
+                        source_pos = node_positions.get(router_method_name)
+                        target_pos = node_positions.get(listener_name)
+
+                        if source_pos and target_pos:
+                            dx = target_pos[0] - source_pos[0]
+                            smooth_type = "curvedCCW" if dx <= 0 else "curvedCW"
+                            index = get_child_index(
+                                router_method_name, listener_name, parent_children
+                            )
+                            edge_smooth = {
+                                "type": smooth_type,
+                                "roundness": 0.2 + (0.1 * index),
+                            }
+                        else:
+                            edge_smooth = {"type": "cubicBezier"}
+                    else:
+                        edge_smooth = False
+
+                    edge_style = {
+                        "color": colors["router_edge"],
+                        "width": 2,
+                        "arrows": "to",
+                        "dashes": True,
+                        "smooth": edge_smooth,
+                    }
+                    net.add_edge(router_method_name, listener_name, **edge_style)
+
+
+def add_nodes_to_network(net, flow, node_positions, node_styles):
+    for method_name, (x, y) in node_positions.items():
+        method = flow._methods.get(method_name)
+        if hasattr(method, "__is_start_method__"):
+            node_style = node_styles["start"]
+        elif hasattr(method, "__is_router__"):
+            node_style = node_styles["router"]
+        else:
+            node_style = node_styles["method"]
+
+        net.add_node(
+            method_name,
+            label=method_name,
+            x=x,
+            y=y,
+            fixed=True,
+            physics=False,
+            **node_style,
+        )
diff --git a/src/crewai/tools/tool_usage.py b/src/crewai/tools/tool_usage.py
index c30207fd..ebc9c591 100644
--- a/src/crewai/tools/tool_usage.py
+++ b/src/crewai/tools/tool_usage.py
@@ -1,5 +1,7 @@
 import ast
+import datetime
 import os
+import time
 from difflib import SequenceMatcher
 from textwrap import dedent
 from typing import Any, List, Union
@@ -8,7 +10,10 @@ from crewai.agents.tools_handler import ToolsHandler
 from crewai.task import Task
 from crewai.telemetry import Telemetry
 from crewai.tools.tool_calling import InstructorToolCalling, ToolCalling
+from crewai.tools.tool_usage_events import ToolUsageError, ToolUsageFinished
 from crewai.utilities import I18N, Converter, ConverterError, Printer
+import crewai.utilities.events as events
+
 
 agentops = None
 if os.environ.get("AGENTOPS_API_KEY"):
@@ -126,12 +131,16 @@ class ToolUsage:
             except Exception:
                 self.task.increment_tools_errors()
 
-        result = None  # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str")
+        started_at = time.time()
+        from_cache = False
 
+        result = None  # type: ignore # Incompatible types in assignment (expression has type "None", variable has type "str")
+        # check if cache is available
         if self.tools_handler.cache:
             result = self.tools_handler.cache.read(  # type: ignore # Incompatible types in assignment (expression has type "str | None", variable has type "str")
                 tool=calling.tool_name, input=calling.arguments
             )
+            from_cache = result is not None
 
         original_tool = next(
             (ot for ot in self.original_tools if ot.name == tool.name), None
@@ -163,6 +172,7 @@ class ToolUsage:
                 else:
                     result = tool.invoke(input={})
             except Exception as e:
+                self.on_tool_error(tool=tool, tool_calling=calling, e=e)
                 self._run_attempts += 1
                 if self._run_attempts > self._max_parsing_attempts:
                     self._telemetry.tool_usage_error(llm=self.function_calling_llm)
@@ -214,6 +224,13 @@ class ToolUsage:
             "tool_args": calling.arguments,
         }
 
+        self.on_tool_use_finished(
+            tool=tool,
+            tool_calling=calling,
+            from_cache=from_cache,
+            started_at=started_at,
+        )
+
         if (
             hasattr(original_tool, "result_as_answer")
             and original_tool.result_as_answer  # type: ignore # Item "None" of "Any | None" has no attribute "cache_function"
@@ -431,3 +448,34 @@ class ToolUsage:
             # Reconstruct the JSON string
             new_json_string = "{" + ", ".join(formatted_entries) + "}"
             return new_json_string
+
+    def on_tool_error(self, tool: Any, tool_calling: ToolCalling, e: Exception) -> None:
+        event_data = self._prepare_event_data(tool, tool_calling)
+        events.emit(
+            source=self, event=ToolUsageError(**{**event_data, "error": str(e)})
+        )
+
+    def on_tool_use_finished(
+        self, tool: Any, tool_calling: ToolCalling, from_cache: bool, started_at: float
+    ) -> None:
+        finished_at = time.time()
+        event_data = self._prepare_event_data(tool, tool_calling)
+        event_data.update(
+            {
+                "started_at": datetime.datetime.fromtimestamp(started_at),
+                "finished_at": datetime.datetime.fromtimestamp(finished_at),
+                "from_cache": from_cache,
+            }
+        )
+        events.emit(source=self, event=ToolUsageFinished(**event_data))
+
+    def _prepare_event_data(self, tool: Any, tool_calling: ToolCalling) -> dict:
+        return {
+            "agent_key": self.agent.key,
+            "agent_role": (self.agent._original_role or self.agent.role),
+            "run_attempts": self._run_attempts,
+            "delegations": self.task.delegations,
+            "tool_name": tool.name,
+            "tool_args": tool_calling.arguments,
+            "tool_class": tool.__class__.__name__,
+        }
diff --git a/src/crewai/tools/tool_usage_events.py b/src/crewai/tools/tool_usage_events.py
new file mode 100644
index 00000000..fea9d3f3
--- /dev/null
+++ b/src/crewai/tools/tool_usage_events.py
@@ -0,0 +1,23 @@
+from typing import Any, Dict
+from pydantic import BaseModel
+from datetime import datetime
+
+
+class ToolUsageEvent(BaseModel):
+    agent_key: str
+    agent_role: str
+    tool_name: str
+    tool_args: Dict[str, Any]
+    tool_class: str
+    run_attempts: int | None = None
+    delegations: int | None = None
+
+
+class ToolUsageFinished(ToolUsageEvent):
+    started_at: datetime
+    finished_at: datetime
+    from_cache: bool = False
+
+
+class ToolUsageError(ToolUsageEvent):
+    error: str
diff --git a/src/crewai/utilities/converter.py b/src/crewai/utilities/converter.py
index 9a2cde75..4d0af67c 100644
--- a/src/crewai/utilities/converter.py
+++ b/src/crewai/utilities/converter.py
@@ -103,10 +103,12 @@ def convert_to_model(
         return handle_partial_json(
             result, model, bool(output_json), agent, converter_cls
         )
+
     except ValidationError:
         return handle_partial_json(
             result, model, bool(output_json), agent, converter_cls
         )
+
     except Exception as e:
         Printer().print(
             content=f"Unexpected error during model conversion: {type(e).__name__}: {e}. Returning original result.",
diff --git a/src/crewai/utilities/crew_json_encoder.py b/src/crewai/utilities/crew_json_encoder.py
index 0df5f546..3cab07ff 100644
--- a/src/crewai/utilities/crew_json_encoder.py
+++ b/src/crewai/utilities/crew_json_encoder.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, date
 import json
 from uuid import UUID
 from pydantic import BaseModel
@@ -11,8 +11,9 @@ class CrewJSONEncoder(json.JSONEncoder):
         elif isinstance(obj, UUID):
             return str(obj)
 
-        elif isinstance(obj, datetime):
+        elif isinstance(obj, datetime) or isinstance(obj, date):
             return obj.isoformat()
+
         return super().default(obj)
 
     def _handle_pydantic_model(self, obj):
diff --git a/src/crewai/utilities/events.py b/src/crewai/utilities/events.py
new file mode 100644
index 00000000..75425fca
--- /dev/null
+++ b/src/crewai/utilities/events.py
@@ -0,0 +1,44 @@
+from typing import Any, Callable, Generic, List, Dict, Type, TypeVar
+from functools import wraps
+from pydantic import BaseModel
+
+
+T = TypeVar("T")
+EVT = TypeVar("EVT", bound=BaseModel)
+
+
+class Emitter(Generic[T, EVT]):
+    _listeners: Dict[Type[EVT], List[Callable]] = {}
+
+    def on(self, event_type: Type[EVT]):
+        def decorator(func: Callable):
+            @wraps(func)
+            def wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            self._listeners.setdefault(event_type, []).append(wrapper)
+            return wrapper
+
+        return decorator
+
+    def emit(self, source: T, event: EVT) -> None:
+        event_type = type(event)
+        for func in self._listeners.get(event_type, []):
+            func(source, event)
+
+
+default_emitter = Emitter[Any, BaseModel]()
+
+
+def emit(source: Any, event: BaseModel, raise_on_error: bool = False) -> None:
+    try:
+        default_emitter.emit(source, event)
+    except Exception as e:
+        if raise_on_error:
+            raise e
+        else:
+            print(f"Error emitting event: {e}")
+
+
+def on(event_type: Type[BaseModel]) -> Callable:
+    return default_emitter.on(event_type)
diff --git a/src/crewai/utilities/pydantic_schema_parser.py b/src/crewai/utilities/pydantic_schema_parser.py
index 9d9cdabe..073280dd 100644
--- a/src/crewai/utilities/pydantic_schema_parser.py
+++ b/src/crewai/utilities/pydantic_schema_parser.py
@@ -1,4 +1,4 @@
-from typing import Type, get_args, get_origin
+from typing import Type, get_args, get_origin, Union
 
 from pydantic import BaseModel
 
@@ -36,7 +36,14 @@ class PydanticSchemaParser(BaseModel):
                 return f"List[\n{nested_schema}\n{' ' * 4 * depth}]"
             else:
                 return f"List[{list_item_type.__name__}]"
-        elif issubclass(field_type, BaseModel):
+        elif get_origin(field_type) is Union:
+            union_args = get_args(field_type)
+            if type(None) in union_args:
+                non_none_type = next(arg for arg in union_args if arg is not type(None))
+                return f"Optional[{self._get_field_type(field.__class__(annotation=non_none_type), depth)}]"
+            else:
+                return f"Union[{', '.join(arg.__name__ for arg in union_args)}]"
+        elif isinstance(field_type, type) and issubclass(field_type, BaseModel):
             return self._get_model_schema(field_type, depth)
         else:
-            return field_type.__name__
+            return getattr(field_type, "__name__", str(field_type))


Test patch:
diff --git a/tests/agent_test.py b/tests/agent_test.py
index 50e94d46..81570268 100644
--- a/tests/agent_test.py
+++ b/tests/agent_test.py
@@ -12,9 +12,11 @@ from crewai.llm import LLM
 from crewai.agents.parser import CrewAgentParser, OutputParserException
 from crewai.tools.tool_calling import InstructorToolCalling
 from crewai.tools.tool_usage import ToolUsage
+from crewai.tools.tool_usage_events import ToolUsageFinished
 from crewai.utilities import RPMController
 from crewai_tools import tool
 from crewai.agents.parser import AgentAction
+from crewai.utilities.events import Emitter
 
 
 def test_agent_llm_creation_with_env_vars():
@@ -71,7 +73,7 @@ def test_agent_creation():
 
 def test_agent_default_values():
     agent = Agent(role="test role", goal="test goal", backstory="test backstory")
-    assert agent.llm.model == "gpt-4o"
+    assert agent.llm.model == "gpt-4o-mini"
     assert agent.allow_delegation is False
 
 
@@ -178,8 +180,15 @@ def test_agent_execution_with_tools():
         agent=agent,
         expected_output="The result of the multiplication.",
     )
-    output = agent.execute_task(task)
-    assert output == "The result of the multiplication is 12."
+    with patch.object(Emitter, "emit") as emit:
+        output = agent.execute_task(task)
+        assert output == "The result of the multiplication is 12."
+        assert emit.call_count == 1
+        args, _ = emit.call_args
+        assert isinstance(args[1], ToolUsageFinished)
+        assert not args[1].from_cache
+        assert args[1].tool_name == "multiplier"
+        assert args[1].tool_args == {"first_number": 3, "second_number": 4}
 
 
 @pytest.mark.vcr(filter_headers=["authorization"])
@@ -197,7 +206,7 @@ def test_logging_tool_usage():
         verbose=True,
     )
 
-    assert agent.llm.model == "gpt-4o"
+    assert agent.llm.model == "gpt-4o-mini"
     assert agent.tools_handler.last_used_tool == {}
     task = Task(
         description="What is 3 times 4?",
@@ -267,7 +276,7 @@ def test_cache_hitting():
         "multiplier-{'first_number': 12, 'second_number': 3}": 36,
     }
 
-    with patch.object(CacheHandler, "read") as read:
+    with patch.object(CacheHandler, "read") as read, patch.object(Emitter, "emit") as emit:
         read.return_value = "0"
         task = Task(
             description="What is 2 times 6? Ignore correctness and just return the result of the multiplication tool, you must use the tool.",
@@ -279,6 +288,10 @@ def test_cache_hitting():
         read.assert_called_with(
             tool="multiplier", input={"first_number": 2, "second_number": 6}
         )
+        assert emit.call_count == 1
+        args, _ = emit.call_args
+        assert isinstance(args[1], ToolUsageFinished)
+        assert args[1].from_cache
 
 
 @pytest.mark.vcr(filter_headers=["authorization"])
diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/authentication/__init__.py b/tests/cli/authentication/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/deploy/test_deploy_main.py b/tests/cli/deploy/test_deploy_main.py
index ddb9709d..b30883be 100644
--- a/tests/cli/deploy/test_deploy_main.py
+++ b/tests/cli/deploy/test_deploy_main.py
@@ -1,7 +1,11 @@
+import pytest
+import requests
+import sys
 import unittest
+
 from io import StringIO
-from unittest.mock import MagicMock, patch
-import sys
+from requests.exceptions import JSONDecodeError
+from unittest.mock import MagicMock, Mock, patch
 
 from crewai.cli.deploy.main import DeployCommand
 from crewai.cli.utils import parse_toml
@@ -33,13 +37,65 @@ class TestDeployCommand(unittest.TestCase):
         with self.assertRaises(SystemExit):
             DeployCommand()
 
-    def test_handle_plus_api_error(self):
+    def test_validate_response_successful_response(self):
+        mock_response = Mock(spec=requests.Response)
+        mock_response.json.return_value = {"message": "Success"}
+        mock_response.status_code = 200
+        mock_response.ok = True
+
+        with patch("sys.stdout", new=StringIO()) as fake_out:
+            self.deploy_command._validate_response(mock_response)
+            assert fake_out.getvalue() == ""
+
+    def test_validate_response_json_decode_error(self):
+        mock_response = Mock(spec=requests.Response)
+        mock_response.json.side_effect = JSONDecodeError("Decode error", "", 0)
+        mock_response.status_code = 500
+        mock_response.content = b"Invalid JSON"
+
+        with patch("sys.stdout", new=StringIO()) as fake_out:
+            with pytest.raises(SystemExit):
+                self.deploy_command._validate_response(mock_response)
+            output = fake_out.getvalue()
+            assert (
+                "Failed to parse response from Enterprise API failed. Details:"
+                in output
+            )
+            assert "Status Code: 500" in output
+            assert "Response:\nb'Invalid JSON'" in output
+
+    def test_validate_response_422_error(self):
+        mock_response = Mock(spec=requests.Response)
+        mock_response.json.return_value = {
+            "field1": ["Error message 1"],
+            "field2": ["Error message 2"],
+        }
+        mock_response.status_code = 422
+        mock_response.ok = False
+
         with patch("sys.stdout", new=StringIO()) as fake_out:
-            self.deploy_command._handle_plus_api_error(
-                {"error": "Test error", "message": "Test message"}
+            with pytest.raises(SystemExit):
+                self.deploy_command._validate_response(mock_response)
+            output = fake_out.getvalue()
+            assert (
+                "Failed to complete operation. Please fix the following errors:"
+                in output
             )
-            self.assertIn("Error: Test error", fake_out.getvalue())
-            self.assertIn("Message: Test message", fake_out.getvalue())
+            assert "Field1 Error message 1" in output
+            assert "Field2 Error message 2" in output
+
+    def test_validate_response_other_error(self):
+        mock_response = Mock(spec=requests.Response)
+        mock_response.json.return_value = {"error": "Something went wrong"}
+        mock_response.status_code = 500
+        mock_response.ok = False
+
+        with patch("sys.stdout", new=StringIO()) as fake_out:
+            with pytest.raises(SystemExit):
+                self.deploy_command._validate_response(mock_response)
+            output = fake_out.getvalue()
+            assert "Request to Enterprise API failed. Details:" in output
+            assert "Details:\nSomething went wrong" in output
 
     def test_standard_no_param_error_message(self):
         with patch("sys.stdout", new=StringIO()) as fake_out:
@@ -87,11 +143,11 @@ class TestDeployCommand(unittest.TestCase):
         mock_display.assert_called_once_with({"uuid": "test-uuid"})
 
     @patch("crewai.cli.deploy.main.fetch_and_json_env_file")
-    @patch("crewai.cli.deploy.main.get_git_remote_url")
+    @patch("crewai.cli.deploy.main.git.Repository.origin_url")
     @patch("builtins.input")
-    def test_create_crew(self, mock_input, mock_get_git_remote_url, mock_fetch_env):
+    def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env):
         mock_fetch_env.return_value = {"ENV_VAR": "value"}
-        mock_get_git_remote_url.return_value = "https://github.com/test/repo.git"
+        mock_git_origin_url.return_value = "https://github.com/test/repo.git"
         mock_input.return_value = ""
 
         mock_response = MagicMock()
@@ -207,30 +263,7 @@ class TestDeployCommand(unittest.TestCase):
         project_name = get_project_name()
         self.assertEqual(project_name, "test_project")
 
-    @patch(
-        "builtins.open",
-        new_callable=unittest.mock.mock_open,
-        read_data="""
-    [[package]]
-    name = "crewai"
-    version = "0.51.1"
-    description = "Some description"
-    category = "main"
-    optional = false
-    python-versions = ">=3.10,<4.0"
-    """,
-    )
-    def test_get_crewai_version(self, mock_open):
-        from crewai.cli.utils import get_crewai_version
-
-        version = get_crewai_version()
-        self.assertEqual(version, "0.51.1")
-
-    @patch("builtins.open", side_effect=FileNotFoundError)
-    def test_get_crewai_version_file_not_found(self, mock_open):
+    def test_get_crewai_version(self):
         from crewai.cli.utils import get_crewai_version
 
-        with patch("sys.stdout", new=StringIO()) as fake_out:
-            version = get_crewai_version()
-            self.assertEqual(version, "no-version-found")
-            self.assertIn("Error: poetry.lock not found.", fake_out.getvalue())
+        assert isinstance(get_crewai_version(), str)
diff --git a/tests/cli/test_git.py b/tests/cli/test_git.py
new file mode 100644
index 00000000..1fb1296c
--- /dev/null
+++ b/tests/cli/test_git.py
@@ -0,0 +1,101 @@
+from crewai.cli.git import Repository
+import pytest
+
+
+@pytest.fixture()
+def repository(fp):
+    fp.register(["git", "--version"], stdout="git version 2.30.0\n")
+    fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
+    fp.register(["git", "fetch"], stdout="")
+    return Repository(path=".")
+
+
+def test_init_with_invalid_git_repo(fp):
+    fp.register(["git", "--version"], stdout="git version 2.30.0\n")
+    fp.register(
+        ["git", "rev-parse", "--is-inside-work-tree"],
+        returncode=1,
+        stderr="fatal: not a git repository\n",
+    )
+
+    with pytest.raises(ValueError):
+        Repository(path="invalid/path")
+
+
+def test_is_git_not_installed(fp):
+    fp.register(["git", "--version"], returncode=1)
+
+    with pytest.raises(
+        ValueError, match="Git is not installed or not found in your PATH."
+    ):
+        Repository(path=".")
+
+
+def test_status(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main [ahead 1]\n",
+    )
+    assert repository.status() == "## main...origin/main [ahead 1]"
+
+
+def test_has_uncommitted_changes(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main\n M somefile.txt\n",
+    )
+    assert repository.has_uncommitted_changes() is True
+
+
+def test_is_ahead_or_behind(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main [ahead 1]\n",
+    )
+    assert repository.is_ahead_or_behind() is True
+
+
+def test_is_synced_when_synced(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"], stdout="## main...origin/main\n"
+    )
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"], stdout="## main...origin/main\n"
+    )
+    assert repository.is_synced() is True
+
+
+def test_is_synced_with_uncommitted_changes(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main\n M somefile.txt\n",
+    )
+    assert repository.is_synced() is False
+
+
+def test_is_synced_when_ahead_or_behind(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main [ahead 1]\n",
+    )
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main [ahead 1]\n",
+    )
+    assert repository.is_synced() is False
+
+
+def test_is_synced_with_uncommitted_changes_and_ahead(fp, repository):
+    fp.register(
+        ["git", "status", "--branch", "--porcelain"],
+        stdout="## main...origin/main [ahead 1]\n M somefile.txt\n",
+    )
+    assert repository.is_synced() is False
+
+
+def test_origin_url(fp, repository):
+    fp.register(
+        ["git", "remote", "get-url", "origin"],
+        stdout="https://github.com/user/repo.git\n",
+    )
+    assert repository.origin_url() == "https://github.com/user/repo.git"
diff --git a/tests/cli/test_plus_api.py b/tests/cli/test_plus_api.py
index 50624629..ace9e4e5 100644
--- a/tests/cli/test_plus_api.py
+++ b/tests/cli/test_plus_api.py
@@ -11,15 +11,22 @@ class TestPlusAPI(unittest.TestCase):
 
     def test_init(self):
         self.assertEqual(self.api.api_key, self.api_key)
-        self.assertEqual(
-            self.api.headers,
-            {
-                "Authorization": f"Bearer {self.api_key}",
-                "Content-Type": "application/json",
-                "User-Agent": "CrewAI-CLI/no-version-found",
-                "X-Crewai-Version": "no-version-found",
-            },
+        self.assertEqual(self.api.headers["Authorization"], f"Bearer {self.api_key}")
+        self.assertEqual(self.api.headers["Content-Type"], "application/json")
+        self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"])
+        self.assertTrue(self.api.headers["X-Crewai-Version"])
+
+    @patch("crewai.cli.plus_api.PlusAPI._make_request")
+    def test_login_to_tool_repository(self, mock_make_request):
+        mock_response = MagicMock()
+        mock_make_request.return_value = mock_response
+
+        response = self.api.login_to_tool_repository()
+
+        mock_make_request.assert_called_once_with(
+            "POST", "/crewai_plus/api/v1/tools/login"
         )
+        self.assertEqual(response, mock_response)
 
     @patch("crewai.cli.plus_api.PlusAPI._make_request")
     def test_get_tool(self, mock_make_request):
diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py
new file mode 100644
index 00000000..616fb3d2
--- /dev/null
+++ b/tests/cli/test_utils.py
@@ -0,0 +1,100 @@
+import pytest
+import shutil
+import tempfile
+import os
+from crewai.cli import utils
+
+
+@pytest.fixture
+def temp_tree():
+    root_dir = tempfile.mkdtemp()
+
+    create_file(os.path.join(root_dir, "file1.txt"), "Hello, world!")
+    create_file(os.path.join(root_dir, "file2.txt"), "Another file")
+    os.mkdir(os.path.join(root_dir, "empty_dir"))
+    nested_dir = os.path.join(root_dir, "nested_dir")
+    os.mkdir(nested_dir)
+    create_file(os.path.join(nested_dir, "nested_file.txt"), "Nested content")
+
+    yield root_dir
+
+    shutil.rmtree(root_dir)
+
+
+def create_file(path, content):
+    with open(path, "w") as f:
+        f.write(content)
+
+
+def test_tree_find_and_replace_file_content(temp_tree):
+    utils.tree_find_and_replace(temp_tree, "world", "universe")
+    with open(os.path.join(temp_tree, "file1.txt"), "r") as f:
+        assert f.read() == "Hello, universe!"
+
+
+def test_tree_find_and_replace_file_name(temp_tree):
+    old_path = os.path.join(temp_tree, "file2.txt")
+    new_path = os.path.join(temp_tree, "file2_renamed.txt")
+    os.rename(old_path, new_path)
+    utils.tree_find_and_replace(temp_tree, "renamed", "modified")
+    assert os.path.exists(os.path.join(temp_tree, "file2_modified.txt"))
+    assert not os.path.exists(new_path)
+
+
+def test_tree_find_and_replace_directory_name(temp_tree):
+    utils.tree_find_and_replace(temp_tree, "empty", "renamed")
+    assert os.path.exists(os.path.join(temp_tree, "renamed_dir"))
+    assert not os.path.exists(os.path.join(temp_tree, "empty_dir"))
+
+
+def test_tree_find_and_replace_nested_content(temp_tree):
+    utils.tree_find_and_replace(temp_tree, "Nested", "Updated")
+    with open(os.path.join(temp_tree, "nested_dir", "nested_file.txt"), "r") as f:
+        assert f.read() == "Updated content"
+
+
+def test_tree_find_and_replace_no_matches(temp_tree):
+    utils.tree_find_and_replace(temp_tree, "nonexistent", "replacement")
+    assert set(os.listdir(temp_tree)) == {
+        "file1.txt",
+        "file2.txt",
+        "empty_dir",
+        "nested_dir",
+    }
+
+
+def test_tree_copy_full_structure(temp_tree):
+    dest_dir = tempfile.mkdtemp()
+    try:
+        utils.tree_copy(temp_tree, dest_dir)
+        assert set(os.listdir(dest_dir)) == set(os.listdir(temp_tree))
+        assert os.path.isfile(os.path.join(dest_dir, "file1.txt"))
+        assert os.path.isfile(os.path.join(dest_dir, "file2.txt"))
+        assert os.path.isdir(os.path.join(dest_dir, "empty_dir"))
+        assert os.path.isdir(os.path.join(dest_dir, "nested_dir"))
+        assert os.path.isfile(os.path.join(dest_dir, "nested_dir", "nested_file.txt"))
+    finally:
+        shutil.rmtree(dest_dir)
+
+
+def test_tree_copy_preserve_content(temp_tree):
+    dest_dir = tempfile.mkdtemp()
+    try:
+        utils.tree_copy(temp_tree, dest_dir)
+        with open(os.path.join(dest_dir, "file1.txt"), "r") as f:
+            assert f.read() == "Hello, world!"
+        with open(os.path.join(dest_dir, "nested_dir", "nested_file.txt"), "r") as f:
+            assert f.read() == "Nested content"
+    finally:
+        shutil.rmtree(dest_dir)
+
+
+def test_tree_copy_to_existing_directory(temp_tree):
+    dest_dir = tempfile.mkdtemp()
+    try:
+        create_file(os.path.join(dest_dir, "existing_file.txt"), "I was here first")
+        utils.tree_copy(temp_tree, dest_dir)
+        assert os.path.isfile(os.path.join(dest_dir, "existing_file.txt"))
+        assert os.path.isfile(os.path.join(dest_dir, "file1.txt"))
+    finally:
+        shutil.rmtree(dest_dir)
diff --git a/tests/cli/tools/test_main.py b/tests/cli/tools/test_main.py
index f387c8d3..9f269091 100644
--- a/tests/cli/tools/test_main.py
+++ b/tests/cli/tools/test_main.py
@@ -1,11 +1,59 @@
+from contextlib import contextmanager
+import tempfile
 import unittest
 import unittest.mock
+import os
 from crewai.cli.tools.main import ToolCommand
 from io import StringIO
 from unittest.mock import patch, MagicMock
 
 
 class TestToolCommand(unittest.TestCase):
+    @contextmanager
+    def in_temp_dir(self):
+        original_dir = os.getcwd()
+        with tempfile.TemporaryDirectory() as temp_dir:
+            os.chdir(temp_dir)
+            try:
+                yield temp_dir
+            finally:
+                os.chdir(original_dir)
+
+    @patch("crewai.cli.tools.main.subprocess.run")
+    def test_create_success(self, mock_subprocess):
+        with self.in_temp_dir():
+            tool_command = ToolCommand()
+
+            with patch.object(tool_command, "login") as mock_login, patch(
+                "sys.stdout", new=StringIO()
+            ) as fake_out:
+                tool_command.create("test-tool")
+                output = fake_out.getvalue()
+
+            self.assertTrue(os.path.isdir("test_tool"))
+
+            self.assertTrue(os.path.isfile(os.path.join("test_tool", "README.md")))
+            self.assertTrue(os.path.isfile(os.path.join("test_tool", "pyproject.toml")))
+            self.assertTrue(
+                os.path.isfile(
+                    os.path.join("test_tool", "src", "test_tool", "__init__.py")
+                )
+            )
+            self.assertTrue(
+                os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py"))
+            )
+
+            with open(
+                os.path.join("test_tool", "src", "test_tool", "tool.py"), "r"
+            ) as f:
+                content = f.read()
+                self.assertIn("class TestTool", content)
+
+            mock_login.assert_called_once()
+            mock_subprocess.assert_called_once_with(["git", "init"], check=True)
+
+            self.assertIn("Creating custom tool test_tool...", output)
+
     @patch("crewai.cli.tools.main.subprocess.run")
     @patch("crewai.cli.plus_api.PlusAPI.get_tool")
     def test_install_success(self, mock_get, mock_subprocess_run):
@@ -13,11 +61,7 @@ class TestToolCommand(unittest.TestCase):
         mock_get_response.status_code = 200
         mock_get_response.json.return_value = {
             "handle": "sample-tool",
-            "repository": {
-                "handle": "sample-repo",
-                "url": "https://example.com/repo",
-                "credentials": "my_very_secret",
-            },
+            "repository": {"handle": "sample-repo", "url": "https://example.com/repo"},
         }
         mock_get.return_value = mock_get_response
         mock_subprocess_run.return_value = MagicMock(stderr=None)
@@ -29,30 +73,6 @@ class TestToolCommand(unittest.TestCase):
             output = fake_out.getvalue()
 
         mock_get.assert_called_once_with("sample-tool")
-        mock_subprocess_run.assert_any_call(
-            [
-                "poetry",
-                "source",
-                "add",
-                "--priority=explicit",
-                "crewai-sample-repo",
-                "https://example.com/repo",
-            ],
-            text=True,
-            check=True,
-        )
-        mock_subprocess_run.assert_any_call(
-            [
-                "poetry",
-                "config",
-                "http-basic.crewai-sample-repo",
-                "my_very_secret",
-                '""',
-            ],
-            capture_output=False,
-            text=True,
-            check=True,
-        )
         mock_subprocess_run.assert_any_call(
             ["poetry", "add", "--source", "crewai-sample-repo", "sample-tool"],
             capture_output=False,
@@ -109,8 +129,10 @@ class TestToolCommand(unittest.TestCase):
         read_data=b"sample tarball content",
     )
     @patch("crewai.cli.plus_api.PlusAPI.publish_tool")
+    @patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=True)
     def test_publish_success(
         self,
+        mock_is_synced,
         mock_publish,
         mock_open,
         mock_listdir,
@@ -127,16 +149,16 @@ class TestToolCommand(unittest.TestCase):
         tool_command = ToolCommand()
         tool_command.publish(is_public=True)
 
-        mock_get_project_name.assert_called_once_with(require=True)
-        mock_get_project_version.assert_called_once_with(require=True)
-        mock_get_project_description.assert_called_once_with(require=False)
-        mock_subprocess_run.assert_called_once_with(
+        mock_get_project_name.assert_called_with(require=True)
+        mock_get_project_version.assert_called_with(require=True)
+        mock_get_project_description.assert_called_with(require=False)
+        mock_subprocess_run.assert_called_with(
             ["poetry", "build", "-f", "sdist", "--output", unittest.mock.ANY],
             check=True,
             capture_output=False,
         )
-        mock_open.assert_called_once_with(unittest.mock.ANY, "rb")
-        mock_publish.assert_called_once_with(
+        mock_open.assert_called_with(unittest.mock.ANY, "rb")
+        mock_publish.assert_called_with(
             handle="sample-tool",
             is_public=True,
             version="1.0.0",
@@ -182,7 +204,7 @@ class TestToolCommand(unittest.TestCase):
             output = fake_out.getvalue()
 
         mock_publish.assert_called_once()
-        self.assertIn("Failed to publish tool", output)
+        self.assertIn("Failed to complete operation", output)
         self.assertIn("Name is already taken", output)
 
     @patch("crewai.cli.tools.main.get_project_name", return_value="sample-tool")
@@ -210,9 +232,11 @@ class TestToolCommand(unittest.TestCase):
         mock_get_project_version,
         mock_get_project_name,
     ):
-        mock_publish_response = MagicMock()
-        mock_publish_response.status_code = 500
-        mock_publish.return_value = mock_publish_response
+        mock_response = MagicMock()
+        mock_response.status_code = 500
+        mock_response.json.return_value = {"error": "Internal Server Error"}
+        mock_response.ok = False
+        mock_publish.return_value = mock_response
 
         tool_command = ToolCommand()
 
@@ -222,8 +246,55 @@ class TestToolCommand(unittest.TestCase):
             output = fake_out.getvalue()
 
         mock_publish.assert_called_once()
-        self.assertIn("Failed to publish tool", output)
+        self.assertIn("Request to Enterprise API failed", output)
 
+    @patch("crewai.cli.plus_api.PlusAPI.login_to_tool_repository")
+    @patch("crewai.cli.tools.main.subprocess.run")
+    def test_login_success(self, mock_subprocess_run, mock_login):
+        mock_login_response = MagicMock()
+        mock_login_response.status_code = 200
+        mock_login_response.json.return_value = {
+            "repositories": [
+                {
+                    "handle": "tools",
+                    "url": "https://example.com/repo",
+                }
+            ],
+            "credential": {"username": "user", "password": "pass"},
+        }
+        mock_login.return_value = mock_login_response
+
+        mock_subprocess_run.return_value = MagicMock(stderr=None)
+
+        tool_command = ToolCommand()
 
-if __name__ == "__main__":
-    unittest.main()
+        with patch("sys.stdout", new=StringIO()) as fake_out:
+            tool_command.login()
+            output = fake_out.getvalue()
+
+        mock_login.assert_called_once()
+        mock_subprocess_run.assert_any_call(
+            [
+                "poetry",
+                "source",
+                "add",
+                "--priority=explicit",
+                "crewai-tools",
+                "https://example.com/repo",
+            ],
+            text=True,
+            check=True,
+        )
+        mock_subprocess_run.assert_any_call(
+            [
+                "poetry",
+                "config",
+                "http-basic.crewai-tools",
+                "user",
+                "pass",
+            ],
+            capture_output=False,
+            text=True,
+            check=True,
+        )
+        self.assertIn("Succesfully authenticated to the tool repository", output)