From 01329a01abf16be7774e2cba18c62b757ce97760 Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Tue, 1 Oct 2024 18:58:27 -0300 Subject: [PATCH] Create `crewai tool create command` (#1379) This commit creates a new CLI command for scaffolding tools. --- src/crewai/cli/cli.py | 7 ++ src/crewai/cli/templates/tool/README.md | 48 +++++++++ src/crewai/cli/templates/tool/pyproject.toml | 14 +++ .../tool/src/{{folder_name}}/__init__.py | 0 .../tool/src/{{folder_name}}/tool.py | 9 ++ src/crewai/cli/tools/main.py | 47 ++++++++ src/crewai/cli/utils.py | 39 +++++++ tests/cli/__init__.py | 0 tests/cli/authentication/__init__.py | 0 tests/cli/test_utils.py | 100 ++++++++++++++++++ tests/cli/tools/test_main.py | 48 +++++++++ 11 files changed, 312 insertions(+) create mode 100644 src/crewai/cli/templates/tool/README.md create mode 100644 src/crewai/cli/templates/tool/pyproject.toml create mode 100644 src/crewai/cli/templates/tool/src/{{folder_name}}/__init__.py create mode 100644 src/crewai/cli/templates/tool/src/{{folder_name}}/tool.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/authentication/__init__.py create mode 100644 tests/cli/test_utils.py diff --git a/src/crewai/cli/cli.py b/src/crewai/cli/cli.py index df2064da5..c8660f346 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 000000000..65cb0ffb2 --- /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 000000000..d02a858ec --- /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 "] +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 000000000..e69de29bb 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 000000000..63c653a6c --- /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 7f6368e2d..45c6e5d46 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 f5e9c4192..6d25c4aeb 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) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/authentication/__init__.py b/tests/cli/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py new file mode 100644 index 000000000..616fb3d2f --- /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 66521f9b3..ec1d71668 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):