Repository: crewAIInc/crewAI
Pull request #1379 (2024-10-01 18:43:51)

Create `crewai tool create <tool> command`
This commit creates a new CLI command for scaffolding tools.


Patch:
diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py
index df2064da..c8660f34 100644
--- a/src/crewai/cli/cli.py
+++ b/src/crewai/cli/cli.py
@@ -260,6 +260,13 @@ 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):
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 7f6368e2..45c6e5d4 100644
--- a/src/crewai/cli/tools/main.py
+++ b/src/crewai/cli/tools/main.py
@@ -1,4 +1,5 @@
 import base64
+from pathlib import Path
 import click
 import os
 import subprocess
@@ -9,6 +10,8 @@ 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,6 +27,37 @@ 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):
         project_name = get_project_name(require=True)
         assert isinstance(project_name, str)
@@ -168,3 +202,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 f5e9c419..6d25c4ae 100644
--- a/src/crewai/cli/utils.py
+++ b/src/crewai/cli/utils.py
@@ -1,3 +1,5 @@
+import os
+import shutil
 import click
 import re
 import subprocess
@@ -198,3 +200,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)


Test patch:
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/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 66521f9b..ec1d7166 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):