mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-09 08:08:32 +00:00
Create crewai tool create <tool> command (#1379)
This commit creates a new CLI command for scaffolding tools.
This commit is contained in:
@@ -260,6 +260,13 @@ def deploy_remove(uuid: Optional[str]):
|
|||||||
deploy_cmd.remove_crew(uuid=uuid)
|
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")
|
@tool.command(name="install")
|
||||||
@click.argument("handle")
|
@click.argument("handle")
|
||||||
def tool_install(handle: str):
|
def tool_install(handle: str):
|
||||||
|
|||||||
48
src/crewai/cli/templates/tool/README.md
Normal file
48
src/crewai/cli/templates/tool/README.md
Normal 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.
|
||||||
14
src/crewai/cli/templates/tool/pyproject.toml
Normal file
14
src/crewai/cli/templates/tool/pyproject.toml
Normal 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"
|
||||||
@@ -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"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import base64
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
import click
|
import click
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -9,6 +10,8 @@ from crewai.cli.utils import (
|
|||||||
get_project_name,
|
get_project_name,
|
||||||
get_project_description,
|
get_project_description,
|
||||||
get_project_version,
|
get_project_version,
|
||||||
|
tree_copy,
|
||||||
|
tree_find_and_replace,
|
||||||
)
|
)
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
@@ -24,6 +27,37 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
|||||||
BaseCommand.__init__(self)
|
BaseCommand.__init__(self)
|
||||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
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):
|
def publish(self, is_public: bool):
|
||||||
project_name = get_project_name(require=True)
|
project_name = get_project_name(require=True)
|
||||||
assert isinstance(project_name, str)
|
assert isinstance(project_name, str)
|
||||||
@@ -168,3 +202,16 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
|
|||||||
if add_package_result.stderr:
|
if add_package_result.stderr:
|
||||||
click.echo(add_package_result.stderr, err=True)
|
click.echo(add_package_result.stderr, err=True)
|
||||||
raise SystemExit
|
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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
import click
|
import click
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -198,3 +200,40 @@ def get_auth_token() -> str:
|
|||||||
if not access_token:
|
if not access_token:
|
||||||
raise Exception()
|
raise Exception()
|
||||||
return access_token
|
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
0
tests/cli/__init__.py
Normal file
0
tests/cli/authentication/__init__.py
Normal file
0
tests/cli/authentication/__init__.py
Normal file
100
tests/cli/test_utils.py
Normal file
100
tests/cli/test_utils.py
Normal 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)
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
import os
|
||||||
from crewai.cli.tools.main import ToolCommand
|
from crewai.cli.tools.main import ToolCommand
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
class TestToolCommand(unittest.TestCase):
|
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.tools.main.subprocess.run")
|
||||||
@patch("crewai.cli.plus_api.PlusAPI.get_tool")
|
@patch("crewai.cli.plus_api.PlusAPI.get_tool")
|
||||||
def test_install_success(self, mock_get, mock_subprocess_run):
|
def test_install_success(self, mock_get, mock_subprocess_run):
|
||||||
|
|||||||
Reference in New Issue
Block a user