From 0d5450fb651224c855a3ab5e911828ee93066aa4 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Wed, 11 Mar 2026 12:19:54 -0500 Subject: [PATCH] Add ClickUp task creation via chat tool, skill, and CLI script New create_task() and find_list_in_folder() methods on ClickUpClient, clickup_create_task chat tool, create-task skill, and CLI script for creating tasks in a client's Overall list with Client/Work Category fields. Co-Authored-By: Claude Opus 4.6 --- cheddahbot/clickup.py | 64 +++++++++++++++ cheddahbot/tools/clickup_tool.py | 65 +++++++++++++++ scripts/create_clickup_task.py | 89 ++++++++++++++++++++ skills/create-task.md | 29 +++++++ tests/test_clickup.py | 135 +++++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 scripts/create_clickup_task.py create mode 100644 skills/create-task.md diff --git a/cheddahbot/clickup.py b/cheddahbot/clickup.py index dff6bc7..8f735f7 100644 --- a/cheddahbot/clickup.py +++ b/cheddahbot/clickup.py @@ -469,6 +469,70 @@ class ClickUpClient: log.warning("Failed to read field '%s' from task %s: %s", field_name, task_id, e) return None + def create_task( + self, + list_id: str, + name: str, + description: str = "", + status: str = "to do", + due_date: int | None = None, + tags: list[str] | None = None, + custom_fields: list[dict] | None = None, + ) -> dict: + """Create a new task in a ClickUp list. + + Args: + list_id: The list to create the task in. + name: Task name. + description: Task description (markdown supported). + status: Initial status (default "to do"). + due_date: Due date as Unix timestamp in milliseconds. + tags: List of tag names to apply. + custom_fields: List of custom field dicts ({"id": ..., "value": ...}). + + Returns: + API response dict containing task id, url, etc. + """ + payload: dict[str, Any] = {"name": name, "status": status} + if description: + payload["description"] = description + if due_date is not None: + payload["due_date"] = due_date + if tags: + payload["tags"] = tags + if custom_fields: + payload["custom_fields"] = custom_fields + + def _call(): + resp = self._client.post(f"/list/{list_id}/task", json=payload) + resp.raise_for_status() + return resp.json() + + result = self._retry(_call) + log.info("Created task '%s' in list %s (id: %s)", name, list_id, result.get("id")) + return result + + def find_list_in_folder( + self, space_id: str, folder_name: str, list_name: str = "Overall" + ) -> str | None: + """Find a list within a named folder in a space. + + Args: + space_id: ClickUp space ID. + folder_name: Folder name to match (case-insensitive). + list_name: List name within the folder (default "Overall"). + + Returns: + The list_id if found, or None. + """ + folders = self.get_folders(space_id) + for folder in folders: + if folder["name"].lower() == folder_name.lower(): + for lst in folder["lists"]: + if lst["name"].lower() == list_name.lower(): + return lst["id"] + return None + def discover_field_filter(self, list_id: str, field_name: str) -> dict[str, Any] | None: """Discover a custom field's UUID and dropdown option map. diff --git a/cheddahbot/tools/clickup_tool.py b/cheddahbot/tools/clickup_tool.py index 50ace06..7bbc75b 100644 --- a/cheddahbot/tools/clickup_tool.py +++ b/cheddahbot/tools/clickup_tool.py @@ -160,6 +160,71 @@ def clickup_task_status(task_id: str, ctx: dict | None = None) -> str: return "\n".join(lines) +@tool( + "clickup_create_task", + "Create a new ClickUp task for a client. Requires task name and client name. " + "Optionally set work category, description, and status. " + "The task is created in the 'Overall' list within the client's folder.", + category="clickup", +) +def clickup_create_task( + name: str, + client: str, + work_category: str = "", + description: str = "", + status: str = "to do", + ctx: dict | None = None, +) -> str: + """Create a new ClickUp task in the client's Overall list.""" + client_obj = _get_clickup_client(ctx) + if not client_obj: + return "Error: ClickUp API token not configured." + + cfg = ctx["config"].clickup + if not cfg.space_id: + return "Error: ClickUp space_id not configured." + + try: + # Find the client's Overall list + list_id = client_obj.find_list_in_folder(cfg.space_id, client) + if not list_id: + return f"Error: Could not find folder '{client}' with an 'Overall' list in space." + + # Create the task + result = client_obj.create_task( + list_id=list_id, + name=name, + description=description, + status=status, + ) + task_id = result.get("id", "") + task_url = result.get("url", "") + + # Set Client dropdown field + client_obj.set_custom_field_by_name(task_id, "Client", client) + + # Set Work Category if provided + if work_category: + field_info = client_obj.discover_field_filter(list_id, "Work Category") + if field_info and work_category in field_info["options"]: + option_id = field_info["options"][work_category] + client_obj.set_custom_field_value(task_id, field_info["field_id"], option_id) + else: + log.warning("Work Category '%s' not found in dropdown options", work_category) + + return ( + f"Task created successfully!\n" + f" Name: {name}\n" + f" Client: {client}\n" + f" ID: {task_id}\n" + f" URL: {task_url}" + ) + except Exception as e: + return f"Error creating task: {e}" + finally: + client_obj.close() + + @tool( "clickup_reset_task", "Reset a ClickUp task to 'to do' status so it can be retried on the next poll. " diff --git a/scripts/create_clickup_task.py b/scripts/create_clickup_task.py new file mode 100644 index 0000000..617025d --- /dev/null +++ b/scripts/create_clickup_task.py @@ -0,0 +1,89 @@ +"""CLI script to create a ClickUp task in a client's Overall list. + +Usage: + uv run python scripts/create_clickup_task.py --name "Task" --client "Client" + uv run python scripts/create_clickup_task.py --name "PR" --client "Acme" \\ + --category "Press Release" +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +# Add project root to path so we can import cheddahbot +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from dotenv import load_dotenv + +from cheddahbot.clickup import ClickUpClient + + +def main(): + load_dotenv() + + parser = argparse.ArgumentParser(description="Create a ClickUp task") + parser.add_argument("--name", required=True, help="Task name") + parser.add_argument("--client", required=True, help="Client folder name in ClickUp") + parser.add_argument("--category", default="", help="Work Category (e.g. 'Press Release')") + parser.add_argument("--description", default="", help="Task description") + parser.add_argument("--status", default="to do", help="Initial status (default: 'to do')") + args = parser.parse_args() + + api_token = os.environ.get("CLICKUP_API_TOKEN", "") + space_id = os.environ.get("CLICKUP_SPACE_ID", "") + + if not api_token: + print("Error: CLICKUP_API_TOKEN not set", file=sys.stderr) + sys.exit(1) + if not space_id: + print("Error: CLICKUP_SPACE_ID not set", file=sys.stderr) + sys.exit(1) + + client = ClickUpClient(api_token=api_token) + try: + # Find the client's Overall list + list_id = client.find_list_in_folder(space_id, args.client) + if not list_id: + msg = f"Error: No folder '{args.client}' with 'Overall' list" + print(msg, file=sys.stderr) + sys.exit(1) + + # Create the task + result = client.create_task( + list_id=list_id, + name=args.name, + description=args.description, + status=args.status, + ) + task_id = result.get("id", "") + + # Set Client dropdown field + client.set_custom_field_by_name(task_id, "Client", args.client) + + # Set Work Category if provided + if args.category: + field_info = client.discover_field_filter(list_id, "Work Category") + if field_info and args.category in field_info["options"]: + option_id = field_info["options"][args.category] + client.set_custom_field_value(task_id, field_info["field_id"], option_id) + else: + msg = f"Warning: Work Category '{args.category}' not found" + print(msg, file=sys.stderr) + + print(json.dumps({ + "id": task_id, + "name": args.name, + "client": args.client, + "url": result.get("url", ""), + "status": args.status, + }, indent=2)) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/skills/create-task.md b/skills/create-task.md new file mode 100644 index 0000000..20efc08 --- /dev/null +++ b/skills/create-task.md @@ -0,0 +1,29 @@ +--- +name: create-task +description: Create new ClickUp tasks for clients. Use when the user asks to create, add, or make a new task. +tools: [clickup_create_task] +agents: [default] +--- + +# Create ClickUp Task + +Creates a new task in the client's "Overall" list in ClickUp. + +## Required Information + +- **name**: The task name (e.g., "Write blog post about AI trends") +- **client**: The client/folder name in ClickUp (e.g., "Acme Corp") + +## Optional Information + +- **work_category**: The work category dropdown value (e.g., "Press Release", "Link Building", "Content Creation", "On Page Optimization") +- **description**: Task description (supports markdown) +- **status**: Initial status (default: "to do") + +## Examples + +"Create a press release task for Acme Corp about their new product launch" +-> name: "Press Release - New Product Launch", client: "Acme Corp", work_category: "Press Release" + +"Add a link building task for Widget Co" +-> name: "Link Building Campaign", client: "Widget Co", work_category: "Link Building" diff --git a/tests/test_clickup.py b/tests/test_clickup.py index 31b3fc4..4f24f1f 100644 --- a/tests/test_clickup.py +++ b/tests/test_clickup.py @@ -434,3 +434,138 @@ class TestClickUpClient: assert result is None client.close() + + @respx.mock + def test_create_task(self): + respx.post(f"{BASE_URL}/list/list_1/task").mock( + return_value=httpx.Response( + 200, + json={ + "id": "new_task_1", + "name": "Test Task", + "url": "https://app.clickup.com/t/new_task_1", + }, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.create_task( + list_id="list_1", + name="Test Task", + description="A test description", + status="to do", + ) + + assert result["id"] == "new_task_1" + assert result["url"] == "https://app.clickup.com/t/new_task_1" + request = respx.calls.last.request + import json + + body = json.loads(request.content) + assert body["name"] == "Test Task" + assert body["description"] == "A test description" + assert body["status"] == "to do" + client.close() + + @respx.mock + def test_create_task_with_optional_fields(self): + respx.post(f"{BASE_URL}/list/list_1/task").mock( + return_value=httpx.Response( + 200, + json={"id": "new_task_2", "name": "Tagged Task", "url": ""}, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.create_task( + list_id="list_1", + name="Tagged Task", + due_date=1740000000000, + tags=["urgent", "mar26"], + custom_fields=[{"id": "cf_1", "value": "opt_1"}], + ) + + assert result["id"] == "new_task_2" + import json + + body = json.loads(respx.calls.last.request.content) + assert body["due_date"] == 1740000000000 + assert body["tags"] == ["urgent", "mar26"] + assert body["custom_fields"] == [{"id": "cf_1", "value": "opt_1"}] + client.close() + + @respx.mock + def test_find_list_in_folder_found(self): + respx.get(f"{BASE_URL}/space/space_1/folder").mock( + return_value=httpx.Response( + 200, + json={ + "folders": [ + { + "id": "f1", + "name": "Acme Corp", + "lists": [ + {"id": "list_overall", "name": "Overall"}, + {"id": "list_archive", "name": "Archive"}, + ], + }, + { + "id": "f2", + "name": "Widget Co", + "lists": [ + {"id": "list_w_overall", "name": "Overall"}, + ], + }, + ] + }, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.find_list_in_folder("space_1", "Acme Corp") + assert result == "list_overall" + client.close() + + @respx.mock + def test_find_list_in_folder_case_insensitive(self): + respx.get(f"{BASE_URL}/space/space_1/folder").mock( + return_value=httpx.Response( + 200, + json={ + "folders": [ + { + "id": "f1", + "name": "Acme Corp", + "lists": [{"id": "list_overall", "name": "Overall"}], + }, + ] + }, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.find_list_in_folder("space_1", "acme corp") + assert result == "list_overall" + client.close() + + @respx.mock + def test_find_list_in_folder_not_found(self): + respx.get(f"{BASE_URL}/space/space_1/folder").mock( + return_value=httpx.Response( + 200, + json={ + "folders": [ + { + "id": "f1", + "name": "Acme Corp", + "lists": [{"id": "list_1", "name": "Overall"}], + }, + ] + }, + ) + ) + + client = ClickUpClient(api_token="pk_test") + result = client.find_list_in_folder("space_1", "NonExistent Client") + assert result is None + client.close()