Create crewai tool create <tool> command (#1379)

This commit creates a new CLI command for scaffolding tools.
This commit is contained in:
Vini Brasil
2024-10-01 18:58:27 -03:00
committed by GitHub
parent 0e11b33f6e
commit 01329a01ab
11 changed files with 312 additions and 0 deletions

View File

@@ -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):

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

0
tests/cli/__init__.py Normal file
View File

View File

100
tests/cli/test_utils.py Normal file
View File

@@ -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)

View File

@@ -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):