feat(cli): prioritize popular platform integrations (#7573)

* feat(cli): prioritize platform integrations

* test(platform): update missing token assertion

* chore: ignore Codex workspace files

* feat(platform): expand integration catalog

* feat(platform): show integration action counts

* feat(platform): select agent actions separately

* feat(platform): group integration applications

* fix(cli): keep platform picker navigation in place

* fix(cli): validate platform apps once

* fix(platform): use one integration token variable

* test(platform): update integration token assertion

* fix(cli): clarify platform token validation

* fix(cli): show platform token prompts

* refactor(platform): expose application catalog

* refactor(platform): export catalog from Python

* docs(platform): document catalog regeneration

* fix(platform): resolve catalog type export
This commit is contained in:
Vidit Ostwal
2026-09-25 21:55:23 +05:30
committed by GitHub
parent dd4a106291
commit a2ea278210
13 changed files with 132247 additions and 136 deletions
+1
View File
@@ -27,6 +27,7 @@ conceptual_plan.md
build_image
chromadb-*.lock
.claude
.codex
.crewai/memory
blogs/*
secrets/*
+177 -33
View File
@@ -12,6 +12,13 @@ from typing import Any
import warnings
import click
from crewai_core.platform_apps import (
PLATFORM_APPLICATION_CATALOG,
PLATFORM_APP_CATEGORIES,
PLATFORM_APP_DISPLAY_NAMES,
PLATFORM_APP_TOOLS,
PLATFORM_APP_TOOL_COUNTS,
)
from crewai_core.telemetry import Telemetry
from rich.console import Console
from rich.text import Text
@@ -19,7 +26,6 @@ from rich.text import Text
from crewai_cli.constants import ENV_VARS
from crewai_cli.git import initialize_if_git_available
from crewai_cli.model_catalog import get_provider_models
from crewai_cli.platform_tools_catalog import PLATFORM_TOOLS
from crewai_cli.tui_picker import pick_many, pick_one
from crewai_cli.utils import (
copy_assistant_instructions,
@@ -109,7 +115,16 @@ _TEMPLATES_DIR = Path(__file__).parent / "templates" / "json_crew"
# ── Common tools for picker ────────────────────────────────────
_TOOL_CATEGORIES: list[tuple[str, list[tuple[str, str]]]] = [
("CrewAI Platform", PLATFORM_TOOLS),
(
"CrewAI Platform",
[
(
f"platform:{application.slug}",
f"{application.display_name} Integration",
)
for application in PLATFORM_APPLICATION_CATALOG
],
),
(
"Search & Research",
[
@@ -234,9 +249,9 @@ _FLAT_TOOLS: list[tuple[str, str]] = [
_COMMON_TOOL_ORDER = [
"SerperDevTool",
"ScrapeWebsiteTool",
"DirectoryReadTool",
"FileReadTool",
"FileWriterTool",
"platform:gmail",
"platform:whatsapp",
]
_ANSI_SEQUENCE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
@@ -313,7 +328,11 @@ def _show_interpolation_hint(kind: str) -> None:
def _tool_label(name: str, description: str) -> str:
if name.startswith("platform:"):
app_name = description.removesuffix(" Integration").replace(" ", "")
return f"{description:<48s} Platform: {app_name.replace(' ', '')}Integration"
tool_count = PLATFORM_APP_TOOL_COUNTS.get(name.removeprefix("platform:"), 0)
return (
f"{description:<48s} Platform: {app_name.replace(' ', '')}Integration "
f"({tool_count} tools)"
)
return f"{description:<48s} {name}"
@@ -322,12 +341,20 @@ def _tool_category_label(category: str) -> str:
def _category_row_label(
category: str, tools: list[tuple[str, str]], selected: set[str], expanded: bool
category: str,
tools: list[tuple[str, str]],
selected: set[str],
expanded: bool,
*,
item_label: str | None = None,
) -> str:
"""Render an accordion category row with tool/selection counts."""
marker = "▾" if expanded else "▸"
sel_count = sum(1 for name, _desc in tools if name in selected)
suffix = f"{len(tools)} tools"
item_label = item_label or (
"applications" if category == "CrewAI Platform" else "tools"
)
suffix = f"{len(tools)} {item_label}"
if sel_count:
suffix += f", {sel_count} selected"
return f"{marker} {category} ({suffix})"
@@ -348,8 +375,26 @@ def _select_tools() -> list[str]:
]
common_tool_names = {name for name, _desc in common_tools}
platform_tools = next(
tools for category, tools in _TOOL_CATEGORIES if category == "CrewAI Platform"
)
platform_groups = [
(
category.name,
[
(
f"platform:{app}",
f"{PLATFORM_APP_DISPLAY_NAMES[app]} Integration",
)
for app in category.apps
],
)
for category in PLATFORM_APP_CATEGORIES
]
categories: list[tuple[str, list[tuple[str, str]]]] = []
for category, category_tools in _TOOL_CATEGORIES:
if category == "CrewAI Platform":
continue
remaining_tools = [
(name, desc)
for name, desc in category_tools
@@ -360,6 +405,7 @@ def _select_tools() -> list[str]:
selected: set[str] = set()
expanded: str | None = None
expanded_platform_group: str | None = None
focus_category: str | None = None
first_render = True
@@ -368,7 +414,7 @@ def _select_tools() -> list[str]:
tool_by_index: dict[int, str] = {}
separator_indices: set[int] = set()
action_indices: set[int] = set()
category_by_index: dict[int, str] = {}
action_by_index: dict[int, tuple[str, str]] = {}
preselected: set[int] = set()
initial_cursor: int | None = None
@@ -380,10 +426,48 @@ def _select_tools() -> list[str]:
tool_by_index[len(labels)] = name
labels.append(_tool_label(name, desc))
platform_row = len(labels)
action_indices.add(platform_row)
action_by_index[platform_row] = ("category", "CrewAI Platform")
if focus_category == "CrewAI Platform":
initial_cursor = platform_row
labels.append(
_category_row_label(
"CrewAI Platform",
platform_tools,
selected,
expanded == "CrewAI Platform",
)
)
if expanded == "CrewAI Platform":
for group, apps in platform_groups:
row = len(labels)
action_indices.add(row)
action_by_index[row] = ("platform_group", group)
is_expanded = group == expanded_platform_group
if group == focus_category:
initial_cursor = row
labels.append(
" "
+ _category_row_label(
group,
apps,
selected,
is_expanded,
item_label="applications",
)
)
if is_expanded:
for name, description in apps:
if name in selected:
preselected.add(len(labels))
tool_by_index[len(labels)] = name
labels.append(" " + _tool_label(name, description))
for category, category_tools in categories:
row = len(labels)
action_indices.add(row)
category_by_index[row] = category
action_by_index[row] = ("category", category)
is_expanded = category == expanded
if category == focus_category:
initial_cursor = row
@@ -415,16 +499,62 @@ def _select_tools() -> list[str]:
if action is None:
break
toggled = category_by_index.get(action)
focus_category = toggled
expanded = None if toggled == expanded else toggled
action_type, category = action_by_index[action]
if action_type == "platform_group":
focus_category = category
expanded_platform_group = (
None if category == expanded_platform_group else category
)
continue
ordered = [name for name, _desc in common_tools] + [
name for _cat, cat_tools in categories for name, _desc in cat_tools
]
focus_category = category
if category == "CrewAI Platform":
expanded = None if category == expanded else category
expanded_platform_group = None
else:
expanded = None if category == expanded else category
expanded_platform_group = None
ordered = (
[name for name, _desc in common_tools]
+ [
f"platform:{app}"
for category in PLATFORM_APP_CATEGORIES
for app in category.apps
if f"platform:{app}" not in common_tool_names
]
+ [name for _cat, cat_tools in categories for name, _desc in cat_tools]
)
return [name for name in ordered if name in selected]
def _select_platform_actions(selected_tools: list[str]) -> list[str]:
"""Replace selected Platform applications with the chosen actions for an agent."""
platform_apps = [
tool.removeprefix("platform:")
for tool in selected_tools
if tool.startswith("platform:") and "/" not in tool
]
non_platform_tools = [
tool for tool in selected_tools if not tool.startswith("platform:")
]
platform_actions = [
tool for tool in selected_tools if tool.startswith("platform:") and "/" in tool
]
for app in platform_apps:
action_indices = pick_many(
f"{PLATFORM_APP_DISPLAY_NAMES[app]} actions for this agent (space to toggle):",
[f"{item.display_name} ({item.slug})" for item in PLATFORM_APP_TOOLS[app]],
)
platform_actions.extend(
f"platform:{app}/{PLATFORM_APP_TOOLS[app][index].slug}"
for index in action_indices
)
return non_platform_tools + platform_actions
def _wizard_agent(
agent_num: int,
existing_names: list[str],
@@ -473,7 +603,7 @@ def _wizard_agent(
else:
llm = _select_model()
tools = _select_tools()
tools = _select_platform_actions(_select_tools())
if tools:
_success(f"{len(tools)} tool{'s' if len(tools) != 1 else ''}")
else:
@@ -877,12 +1007,12 @@ def _setup_env(folder_path: Path, llm_model: str) -> None:
def _platform_apps_from_agents(agents: list[dict[str, Any]]) -> list[str]:
"""Return unique platform applications selected across all agents."""
"""Return unique platform application slugs selected across all agents."""
apps: list[str] = []
for agent in agents:
for tool in agent.get("tools", []):
if isinstance(tool, str) and tool.startswith("platform:"):
app = tool.removeprefix("platform:")
app = tool.removeprefix("platform:").split("/", maxsplit=1)[0]
if app and app not in apps:
apps.append(app)
return apps
@@ -890,28 +1020,24 @@ def _platform_apps_from_agents(agents: list[dict[str, Any]]) -> list[str]:
def _platform_app_name(app: str) -> str:
"""Return the display name for a platform application slug."""
return (
dict(PLATFORM_TOOLS)
.get(f"platform:{app}", app.replace("_", " ").title())
.removesuffix(" Integration")
)
return PLATFORM_APP_DISPLAY_NAMES.get(app, app.replace("_", " ").title())
def _prompt_platform_token() -> str:
"""Explain how to obtain and securely prompt for an AMP integration token."""
"""Explain how to prompt for an AMP Enterprise Action Auth Token."""
click.secho(
" To use CrewAI Platform tools, you need a CrewAI Platform Integration Token.",
" To use CrewAI Platform tools, you need a CrewAI Platform Enterprise Action Auth Token.",
fg="yellow",
)
click.secho(
" Get your token from CrewAI AMP: https://app.crewai.com "
"→ Settings → Integration Tokens.",
"→ Settings → Account → Integration Token.",
fg="cyan",
)
return str(
click.prompt(
click.style(" CREWAI_PLATFORM_INTEGRATION_TOKEN", fg="cyan"),
hide_input=True,
hide_input=False,
prompt_suffix=click.style(" > ", fg="bright_white"),
)
).strip()
@@ -954,7 +1080,7 @@ def _report_platform_app_validation(
status_code = getattr(getattr(error, "response", None), "status_code", None)
if status_code in {401, 403}:
click.secho(
" ✘ CrewAI Platform Integration Token is invalid or expired",
" ✘ CrewAI Platform Integration token is invalid or expired",
fg="red",
)
return True
@@ -1008,7 +1134,7 @@ def _validate_platform_apps(
app_name = _platform_app_name(app)
click.echo()
click.secho(
" Checking CrewAI Platform Integration Token and "
" Checking CrewAI Platform Integration token and "
f"{app_name} integration on AMP...",
fg="cyan",
)
@@ -1027,7 +1153,7 @@ def _show_platform_validation_guidance(
click.echo()
if token_invalid:
click.secho(
" Check your CrewAI Platform Integration Token in AMP.",
" Check your CrewAI Platform Integration token in AMP.",
fg="yellow",
)
return
@@ -1037,7 +1163,7 @@ def _show_platform_validation_guidance(
" Check the "
f"{', '.join(failed_app_names)} integration"
f"{'s' if len(failed_app_names) != 1 else ''} and your CrewAI "
"Platform Integration Token in AMP.",
"Platform Integration token in AMP.",
fg="yellow",
)
@@ -1053,7 +1179,7 @@ def _prompt_platform_revalidation_token() -> str:
),
default="",
show_default=False,
hide_input=True,
hide_input=False,
prompt_suffix=click.style(" > ", fg="bright_white"),
)
).strip()
@@ -1082,18 +1208,35 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
"`pip install 'crewai[tools]'`."
) from error
click.secho(
" Checking for CREWAI_PLATFORM_INTEGRATION_TOKEN...",
fg="cyan",
)
token = os.environ.get("CREWAI_PLATFORM_INTEGRATION_TOKEN", "")
credential_location: str | None = None
if token:
credential_location = "CREWAI_PLATFORM_INTEGRATION_TOKEN environment variable"
while True:
if not token:
click.secho(
" No CREWAI_PLATFORM_INTEGRATION_TOKEN found; prompting for one.",
fg="yellow",
)
token = _prompt_platform_token()
if token:
credential_location = "interactive prompt"
if not token:
click.secho(
" A CrewAI Platform Integration Token is required to validate "
" A CrewAI Platform Integration token is required to validate "
"the selected integrations.",
fg="yellow",
)
continue
_success(
f"Platform Integration token found via {credential_location}", dim=True
)
os.environ["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = token
failed_apps, token_invalid = _validate_platform_apps(
apps, ApplicationSelector, client_for_selector
@@ -1110,6 +1253,7 @@ def _setup_platform_auth(agents: list[dict[str, Any]]) -> str | None:
replacement_token = _prompt_platform_revalidation_token()
if replacement_token:
token = replacement_token
credential_location = "interactive replacement prompt"
os.environ["CREWAI_PLATFORM_INTEGRATION_TOKEN"] = token
@@ -1,29 +0,0 @@
"""CrewAI AMP platform tools exposed by the crew-creation wizard."""
from crewai_core.platform_apps import PLATFORM_APPS
PLATFORM_TOOL_PREFIX = "platform:"
_APP_DESCRIPTIONS: dict[str, str] = {
"asana": "Asana Integration",
"box": "Box Integration",
"clickup": "ClickUp Integration",
"github": "GitHub Integration",
"gmail": "Gmail Integration",
"google_calendar": "Google Calendar Integration",
"google_sheets": "Google Sheets Integration",
"hubspot": "HubSpot Integration",
"jira": "Jira Integration",
"linear": "Linear Integration",
"notion": "Notion Integration",
"salesforce": "Salesforce Integration",
"shopify": "Shopify Integration",
"slack": "Slack Integration",
"stripe": "Stripe Integration",
"zendesk": "Zendesk Integration",
}
PLATFORM_TOOLS: list[tuple[str, str]] = [
(f"{PLATFORM_TOOL_PREFIX}{app}", _APP_DESCRIPTIONS[app]) for app in PLATFORM_APPS
]
+30 -11
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from contextlib import suppress
import shutil
import sys
from typing import overload
@@ -96,18 +97,22 @@ def _draw_multi(
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
clear: bool = False,
) -> None:
previous_line_count: int | None = None,
) -> int:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
start, end = _visible_row_range(len(labels), cursor)
hint_text = "↑↓ navigate, space toggle, enter confirm"
if action_indices:
hint_text = "↑↓ navigate, space toggle, enter confirm, ▸ rows expand/collapse"
if end - start < len(labels):
hint_text += f" · showing {start + 1}-{end} of {len(labels)}"
hint = f" {_DIM}{hint_text}{_RESET}"
total = len(labels) + 1
if clear:
sys.stdout.write(f"\033[{total}A")
sys.stdout.write(f"\033[{previous_line_count or 1}A")
sys.stdout.write(f"\033[2K{hint}\n")
for i, label in enumerate(labels):
for i in range(start, end):
label = labels[i]
if i in separator_indices:
sys.stdout.write(f"\033[2K {_TEAL}{label}{_RESET}\n")
continue
@@ -121,6 +126,17 @@ def _draw_multi(
bold = f"{_BOLD}{label}{_RESET}" if i == cursor else label
sys.stdout.write(f"\033[2K {arrow}{check} {bold}\n")
sys.stdout.flush()
return end - start + 1
def _visible_row_range(total: int, cursor: int) -> tuple[int, int]:
"""Return the portion of a multi-select list that fits in the terminal."""
max_rows = max(5, shutil.get_terminal_size(fallback=(80, 24)).lines - 5)
if total <= max_rows:
return 0, total
start = min(max(cursor - max_rows // 2, 0), total - max_rows)
return start, start + max_rows
def _arrow_select_one(labels: list[str]) -> int:
@@ -168,7 +184,7 @@ def _arrow_select_multi(
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_multi(
rendered_lines = _draw_multi(
labels,
cursor,
selected,
@@ -179,44 +195,47 @@ def _arrow_select_multi(
key = _read_key()
if key == "up":
cursor = _next_selectable_index(cursor, -1, total, separator_indices)
_draw_multi(
rendered_lines = _draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
previous_line_count=rendered_lines,
)
elif key == "down":
cursor = _next_selectable_index(cursor, 1, total, separator_indices)
_draw_multi(
rendered_lines = _draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
previous_line_count=rendered_lines,
)
elif key == "space":
if cursor in action_indices:
_clear_lines(total + 1)
_clear_lines(rendered_lines)
return sorted(selected), cursor
selected ^= {cursor}
_draw_multi(
rendered_lines = _draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
previous_line_count=rendered_lines,
)
elif key == "enter":
_clear_lines(total + 1)
_clear_lines(rendered_lines)
if cursor in action_indices:
return sorted(selected), cursor
return sorted(selected), None
elif key in ("esc", "q"):
_clear_lines(total + 1)
_clear_lines(rendered_lines)
return sorted(selected), None
finally:
sys.stdout.write(_SHOW_CURSOR)
+134 -19
View File
@@ -470,16 +470,18 @@ def test_json_wizard_tool_picker_prioritizes_common_tools(monkeypatch):
tools = json_crew._select_tools()
assert tools == ["SerperDevTool", "DirectoryReadTool"]
assert tools == ["SerperDevTool", "FileReadTool"]
assert len(picker_calls) == 1
labels = picker_calls[0][1]
assert 0 in picker_calls[0][2]["separator_indices"]
assert labels[0] == "── Common tools ──"
assert labels[1].strip().endswith("SerperDevTool")
assert labels[2].strip().endswith("ScrapeWebsiteTool")
assert labels[3].strip().endswith("DirectoryReadTool")
assert labels[4].strip().endswith("FileReadTool")
assert labels[5].strip().endswith("FileWriterTool")
assert labels[3].strip().endswith("FileReadTool")
assert labels[4].startswith("Gmail Integration")
assert labels[4].endswith("Platform: GmailIntegration (66 tools)")
assert labels[5].startswith("WhatsApp Integration")
assert labels[5].endswith("Platform: WhatsAppIntegration (58 tools)")
assert labels[1].index("Google search") < labels[1].index("SerperDevTool")
assert "More tools" not in labels
@@ -504,6 +506,7 @@ def test_json_wizard_tool_picker_collapses_categories_by_default(monkeypatch):
assert len(action_indices) >= 4
# Only the common tools section is visible beyond the category rows
assert len(labels) == 1 + 5 + len(action_indices)
assert any(label == "▸ CrewAI Platform (125 applications)" for label in labels)
def test_json_wizard_tool_picker_expands_one_category_at_a_time(monkeypatch):
@@ -584,15 +587,26 @@ def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypat
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
expanded_labels: list[str] = []
categories = [
"Search & Research",
"Web Scraping",
"File & Document",
"Code & Data",
"Cloud & Storage",
"Sandbox & Automation",
"AI & Vision",
]
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
expanded_labels.extend(labels)
action_indices = sorted(kwargs["action_indices"])
call_num = len(picker_calls)
if call_num <= len(action_indices):
# Expand the n-th category (indices shift between renders, so
# recompute from this render's action rows)
return [], action_indices[call_num - 1]
if call_num <= len(categories):
category = categories[call_num - 1]
category_row = next(
idx for idx, label in enumerate(labels) if category in label
)
return [], category_row
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
@@ -637,13 +651,28 @@ def test_json_wizard_platform_tool_selection_stays_in_agent_tools(monkeypatch):
)
return [], platform_row
github = next(
if picker_calls == 2:
engineering = next(
idx
for idx, label in enumerate(labels)
if "Engineering, data & infrastructure" in label
)
return [], engineering
if picker_calls == 3:
github = next(
idx
for idx, label in enumerate(labels)
if label.strip().startswith("GitHub Integration")
)
return [github], None
create_repository = next(
idx
for idx, label in enumerate(labels)
if label.startswith("GitHub Integration")
and label.endswith("Platform: GitHubIntegration")
if label == "Create repository (create_repository)"
)
return [github], None
return [create_repository]
monkeypatch.setattr(json_crew, "pick_many", pick_many)
monkeypatch.setattr(
@@ -655,8 +684,56 @@ def test_json_wizard_platform_tool_selection_stays_in_agent_tools(monkeypatch):
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
assert agent is not None
assert agent["tools"] == ["platform:github"]
assert '"tools": ["platform:github"]' in json_crew._agent_to_jsonc(agent)
assert agent["tools"] == ["platform:github/create_repository"]
assert (
'"tools": ["platform:github/create_repository"]'
in json_crew._agent_to_jsonc(agent)
)
def test_platform_applications_expand_in_the_main_tool_picker(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
if len(picker_calls) == 1:
platform = next(
index
for index, label in enumerate(labels)
if "CrewAI Platform" in label
)
return [], platform
if len(picker_calls) == 2:
group = next(
index
for index, label in enumerate(labels)
if "Engineering, data & infrastructure" in label
)
return [], group
github = next(
index
for index, label in enumerate(labels)
if label.strip().startswith("GitHub Integration")
)
return [github], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
assert json_crew._select_tools() == ["platform:github"]
labels = picker_calls[1][1]
assert labels[0] == "── Common tools ──"
assert any(
label == " ▸ Google Workspace & Google Cloud (17 applications)"
for label in labels
)
assert any(
label == " ▸ Engineering, data & infrastructure (11 applications)"
for label in labels
)
assert picker_calls[1][2]["initial_cursor"] == next(
index for index, label in enumerate(labels) if "CrewAI Platform" in label
)
assert all(title != "Platform applications (space to toggle, enter to return):" for title, *_ in picker_calls)
def test_json_wizard_platform_catalog_contains_every_supported_app():
@@ -671,8 +748,39 @@ def test_json_wizard_platform_catalog_contains_every_supported_app():
]
def test_platform_validation_deduplicates_actions_by_application() -> None:
apps = json_crew._platform_apps_from_agents(
[
{
"tools": [
"platform:github/create_repository",
"platform:github/get_repository_content",
"platform:gmail/send_email",
]
},
{"tools": ["platform:github/list_repository_issues"]},
]
)
assert apps == ["github", "gmail"]
def test_platform_token_prompts_show_entered_values(monkeypatch) -> None:
prompt_options: list[dict[str, object]] = []
def prompt(_label: str, **kwargs: object) -> str:
prompt_options.append(kwargs)
return "token"
monkeypatch.setattr(json_crew.click, "prompt", prompt)
assert json_crew._prompt_platform_token() == "token"
assert json_crew._prompt_platform_revalidation_token() == "token"
assert [options["hide_input"] for options in prompt_options] == [False, False]
def test_platform_auth_suppresses_warnings_only_while_importing_tools(
monkeypatch,
monkeypatch, capsys
):
class FakeApplicationSelector:
@classmethod
@@ -694,6 +802,7 @@ def test_platform_auth_suppresses_warnings_only_while_importing_tools(
return original_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", import_with_warning)
monkeypatch.setenv("CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN", "ignored-token")
monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "test-token")
with warnings.catch_warnings(record=True) as caught_warnings:
@@ -704,6 +813,12 @@ def test_platform_auth_suppresses_warnings_only_while_importing_tools(
assert [str(warning.message) for warning in caught_warnings] == [
"warning after import"
]
output = capsys.readouterr().out
assert "Checking for CREWAI_PLATFORM_INTEGRATION_TOKEN" in output
assert (
"Platform Integration token found via "
"CREWAI_PLATFORM_INTEGRATION_TOKEN environment variable" in output
)
def test_platform_validation_checks_apps_concurrently():
@@ -749,7 +864,7 @@ def test_platform_validation_announces_concurrent_apps_together(capsys):
output = capsys.readouterr().out
assert (failed_apps, token_invalid) == ([], False)
assert "Checking GitHub, Gmail, Google Calendar integrations together on AMP" in output
assert "Checking CrewAI Platform Integration Token and GitHub" not in output
assert "Checking CrewAI Platform Integration token and GitHub" not in output
def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys):
@@ -779,8 +894,8 @@ def test_platform_validation_falls_back_to_sequential_checks(monkeypatch, capsys
assert (failed_apps, token_invalid) == ([], False)
assert checked_apps == ["github", "gmail"]
output = capsys.readouterr().out
assert "Checking CrewAI Platform Integration Token and GitHub integration" in output
assert "Checking CrewAI Platform Integration Token and Gmail integration" in output
assert "Checking CrewAI Platform Integration token and GitHub integration" in output
assert "Checking CrewAI Platform Integration token and Gmail integration" in output
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
+50
View File
@@ -0,0 +1,50 @@
import os
from io import StringIO
from crewai_cli import tui_picker
def test_visible_row_range_keeps_short_lists_fully_visible(monkeypatch) -> None:
monkeypatch.setattr(
tui_picker.shutil,
"get_terminal_size",
lambda fallback: os.terminal_size((80, 24)),
)
assert tui_picker._visible_row_range(total=8, cursor=3) == (0, 8)
def test_visible_row_range_scrolls_long_lists_around_cursor(monkeypatch) -> None:
monkeypatch.setattr(
tui_picker.shutil,
"get_terminal_size",
lambda fallback: os.terminal_size((80, 12)),
)
assert tui_picker._visible_row_range(total=125, cursor=0) == (0, 7)
assert tui_picker._visible_row_range(total=125, cursor=62) == (59, 66)
assert tui_picker._visible_row_range(total=125, cursor=124) == (118, 125)
def test_draw_multi_renders_only_the_visible_window(monkeypatch) -> None:
monkeypatch.setattr(
tui_picker.shutil,
"get_terminal_size",
lambda fallback: os.terminal_size((80, 12)),
)
output = StringIO()
monkeypatch.setattr(tui_picker.sys, "stdout", output)
line_count = tui_picker._draw_multi(
[f"Tool {index}" for index in range(125)],
cursor=62,
selected=set(),
)
rendered = output.getvalue()
assert line_count == 8
assert "showing 60-66 of 125" in rendered
assert "Tool 59" in rendered
assert "Tool 65" in rendered
assert "Tool 0" not in rendered
assert "Tool 124" not in rendered
+308 -20
View File
@@ -1,25 +1,313 @@
"""CrewAI Platform application catalog."""
from typing import Final, Literal, get_args
from dataclasses import dataclass
from typing import Any, Final, TypeAlias
from crewai_core.platform_catalog import PLATFORM_CATALOG
PlatformApp = Literal[
"asana",
"box",
"clickup",
"github",
"gmail",
"google_calendar",
"google_sheets",
"hubspot",
"jira",
"linear",
"notion",
"salesforce",
"shopify",
"slack",
"stripe",
"zendesk",
]
PlatformApp: TypeAlias = str
PLATFORM_APPS: Final[tuple[str, ...]] = (*get_args(PlatformApp),)
@dataclass(frozen=True)
class PlatformToolDefinition:
"""Describe one selectable CrewAI Platform action."""
slug: str
display_name: str
@dataclass(frozen=True)
class PlatformApplicationDefinition:
"""Describe one CrewAI Platform application and its available actions."""
slug: PlatformApp
display_name: str
tools: tuple[PlatformToolDefinition, ...]
@dataclass(frozen=True)
class PlatformAppCategory:
"""Describe a presentation group of CrewAI Platform applications."""
name: str
apps: tuple[PlatformApp, ...]
def _load_platform_catalog(
catalog: dict[str, Any],
) -> tuple[PlatformApplicationDefinition, ...]:
"""Load and validate the generated Clipper application catalog."""
applications = catalog.get("applications")
if not isinstance(applications, list):
raise ValueError("Platform catalog must contain an 'applications' list.")
applications_by_slug: set[str] = set()
parsed_applications: list[PlatformApplicationDefinition] = []
for application in applications:
if not isinstance(application, dict):
raise ValueError("Every platform catalog application must be an object.")
slug = application.get("slug")
display_name = application.get("display_name")
if not isinstance(slug, str) or not slug:
raise ValueError("Every platform catalog application must have a slug.")
if not isinstance(display_name, str) or not display_name:
raise ValueError(
f"Platform catalog application '{slug}' must have a display name."
)
if slug in applications_by_slug:
raise ValueError(f"Platform catalog contains duplicate slug '{slug}'.")
tools = application.get("tools", [])
if not isinstance(tools, list):
raise ValueError(
f"Platform catalog application '{slug}' must have a tools list."
)
parsed_tools: list[PlatformToolDefinition] = []
for tool in tools:
if not isinstance(tool, dict):
raise ValueError(
f"Platform catalog application '{slug}' contains an invalid tool."
)
tool_slug = tool.get("slug")
tool_display_name = tool.get("display_name")
if not isinstance(tool_slug, str) or not tool_slug:
raise ValueError(
f"Platform catalog application '{slug}' contains a tool without a slug."
)
if not isinstance(tool_display_name, str) or not tool_display_name:
raise ValueError(
f"Platform catalog tool '{slug}/{tool_slug}' must have a display name."
)
parsed_tools.append(
PlatformToolDefinition(
slug=tool_slug,
display_name=tool_display_name,
)
)
applications_by_slug.add(slug)
parsed_applications.append(
PlatformApplicationDefinition(
slug=slug,
display_name=display_name,
tools=tuple(parsed_tools),
)
)
return tuple(parsed_applications)
PLATFORM_APPLICATION_CATALOG: Final[tuple[PlatformApplicationDefinition, ...]] = (
_load_platform_catalog(PLATFORM_CATALOG)
)
PLATFORM_APPS: Final[tuple[str, ...]] = tuple(
application.slug for application in PLATFORM_APPLICATION_CATALOG
)
PLATFORM_APP_DISPLAY_NAMES: Final[dict[str, str]] = {
application.slug: application.display_name
for application in PLATFORM_APPLICATION_CATALOG
}
PLATFORM_APP_TOOL_COUNTS: Final[dict[str, int]] = {
application.slug: len(application.tools)
for application in PLATFORM_APPLICATION_CATALOG
}
PLATFORM_APP_TOOLS: Final[dict[str, tuple[PlatformToolDefinition, ...]]] = {
application.slug: application.tools for application in PLATFORM_APPLICATION_CATALOG
}
def _platform_app_categories() -> tuple[PlatformAppCategory, ...]:
"""Return the curated application groups used by integration pickers."""
return (
PlatformAppCategory(
"Google Workspace & Google Cloud",
(
"gmail",
"google_calendar",
"google_docs",
"google_drive",
"google_sheets",
"google_slides",
"google_contacts",
"google_classroom",
"googlemeet",
"googlephotos",
"googletasks",
"googlesuper",
"google_maps",
"google_search_console",
"google_analytics",
"googleads",
"googlebigquery",
),
),
PlatformAppCategory(
"Microsoft 365",
(
"microsoft_excel",
"microsoft_onedrive",
"microsoft_outlook",
"microsoft_sharepoint",
"microsoft_teams",
"microsoft_word",
),
),
PlatformAppCategory(
"Project & collaboration",
(
"airtable",
"asana",
"basecamp",
"clickup",
"confluence",
"dart",
"jira",
"linear",
"monday",
"notion",
"productboard",
"todoist",
"trello",
"ticktick",
"wrike",
),
),
PlatformAppCategory(
"Design & content",
("canva", "contentful", "figma", "miro", "mural", "zeplin"),
),
PlatformAppCategory(
"Communication, meetings & scheduling",
(
"cal",
"calendly",
"dialpad",
"discord",
"discordbot",
"fathom",
"gong",
"granola_mcp",
"slack",
"slackbot",
"whatsapp",
"zoom",
"zoho_mail",
),
),
PlatformAppCategory(
"CRM, sales & customer support",
(
"attio",
"dynamics365",
"gorgias",
"hubspot",
"intercom",
"pylon_mcp",
"salesforce",
"zendesk",
"zoho",
"zoho_bigin",
"zoho_desk",
),
),
PlatformAppCategory(
"Marketing, social & audience",
(
"dub",
"eventbrite",
"facebook",
"instagram",
"kit",
"linkedin",
"mailchimp",
"omnisend",
"pinterest",
"pinterest_ads",
"reddit",
"reddit_ads",
"tiktok_ads",
"toneden",
"twitch",
"typeform",
"youtube",
),
),
PlatformAppCategory(
"Finance & commerce",
(
"freshbooks",
"gumroad",
"mercury_mcp",
"moneybird",
"quickbooks",
"shippo",
"shopify",
"square",
"splitwise",
"stripe",
"ynab",
"zoho_books",
),
),
PlatformAppCategory(
"Business operations & HR",
("apaleo", "greenhouse", "harvest", "sap_s4hana", "servicem8", "timely"),
),
PlatformAppCategory(
"Engineering, data & infrastructure",
(
"bitbucket",
"daytona",
"digital_ocean",
"github",
"gitlab",
"hugging_face",
"pagerduty",
"prisma",
"sentry",
"supabase",
"wakatime",
),
),
PlatformAppCategory(
"Files, documents & knowledge",
("boldsign", "box", "dropbox", "linkhut", "notebook_lm", "roam"),
),
PlatformAppCategory(
"Personal, utilities & reference",
("exist", "pushbullet", "stack_exchange", "ticketmaster", "yandex"),
),
)
def _validate_platform_app_categories(
categories: tuple[PlatformAppCategory, ...], apps: tuple[PlatformApp, ...]
) -> tuple[PlatformAppCategory, ...]:
"""Ensure every catalog application belongs to exactly one category."""
categorized_apps = [app for category in categories for app in category.apps]
duplicates = {app for app in categorized_apps if categorized_apps.count(app) > 1}
if duplicates:
raise ValueError(
"Platform app categories contain duplicate applications: "
f"{', '.join(sorted(duplicates))}."
)
categorized_app_set = set(categorized_apps)
app_set = set(apps)
unknown = categorized_app_set - app_set
if unknown:
raise ValueError(
"Platform app categories contain unknown applications: "
f"{', '.join(sorted(unknown))}."
)
missing = app_set - categorized_app_set
if missing:
raise ValueError(
"Platform app categories are missing applications: "
f"{', '.join(sorted(missing))}."
)
return categories
PLATFORM_APP_CATEGORIES: Final[tuple[PlatformAppCategory, ...]] = (
_validate_platform_app_categories(_platform_app_categories(), PLATFORM_APPS)
)
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,15 @@ This module provides tools for integrating with various platform applications
through the CrewAI platform API.
"""
from crewai_core.platform_apps import PLATFORM_APPS
from crewai_core.platform_apps import (
PLATFORM_APPLICATION_CATALOG,
PLATFORM_APPS,
PLATFORM_APP_CATEGORIES,
PLATFORM_APP_DISPLAY_NAMES,
PLATFORM_APP_TOOLS,
PLATFORM_APP_TOOL_COUNTS,
)
from crewai_core.platform_catalog import PLATFORM_CATALOG
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
CrewAIPlatformActionTool,
@@ -15,7 +23,13 @@ from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
__all__ = [
"PLATFORM_APPLICATION_CATALOG",
"PLATFORM_APPS",
"PLATFORM_APP_CATEGORIES",
"PLATFORM_APP_DISPLAY_NAMES",
"PLATFORM_APP_TOOLS",
"PLATFORM_APP_TOOL_COUNTS",
"PLATFORM_CATALOG",
"CrewAIPlatformActionTool",
"CrewaiPlatformTools",
]
@@ -2,10 +2,11 @@ import os
def get_platform_integration_token() -> str:
"""Get the platform integration token from the environment."""
token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or ""
"""Get the Platform integration token from the environment."""
token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "")
if not token:
raise ValueError(
"No platform integration token found, please set the CREWAI_PLATFORM_INTEGRATION_TOKEN environment variable"
"No Platform integration token found, please set the "
"CREWAI_PLATFORM_INTEGRATION_TOKEN environment variable"
)
return token # TODO: Use context manager to get token
@@ -1,22 +1,37 @@
from crewai_core.platform_apps import PLATFORM_APPS
from crewai_core.platform_apps import (
PLATFORM_CATALOG,
PLATFORM_APP_CATEGORIES,
PLATFORM_APP_DISPLAY_NAMES,
PLATFORM_APP_TOOL_COUNTS,
PLATFORM_APPLICATION_CATALOG,
PLATFORM_APPS,
)
def test_platform_apps_contains_supported_application_catalog() -> None:
assert PLATFORM_APPS == (
"asana",
"box",
"clickup",
"github",
"gmail",
"google_calendar",
"google_sheets",
"hubspot",
"jira",
"linear",
"notion",
"salesforce",
"shopify",
"slack",
"stripe",
"zendesk",
assert isinstance(PLATFORM_CATALOG["applications"], list)
assert len(PLATFORM_APPS) == 125
assert len(set(PLATFORM_APPS)) == len(PLATFORM_APPS)
assert PLATFORM_APPS[0] == "airtable"
assert PLATFORM_APPS[-1] == "sap_s4hana"
assert set(PLATFORM_APP_DISPLAY_NAMES) == set(PLATFORM_APPS)
assert set(PLATFORM_APP_TOOL_COUNTS) == set(PLATFORM_APPS)
assert PLATFORM_APP_DISPLAY_NAMES["github"] == "GitHub"
assert PLATFORM_APP_TOOL_COUNTS["github"] == 877
assert tuple(application.slug for application in PLATFORM_APPLICATION_CATALOG) == (
PLATFORM_APPS
)
assert next(
application for application in PLATFORM_APPLICATION_CATALOG if application.slug == "github"
).display_name == "GitHub"
def test_platform_app_categories_cover_the_catalog_once() -> None:
categorized_apps = [
app for category in PLATFORM_APP_CATEGORIES for app in category.apps
]
assert len(PLATFORM_APP_CATEGORIES) == 12
assert set(categorized_apps) == set(PLATFORM_APPS)
assert len(categorized_apps) == len(set(categorized_apps))
assert PLATFORM_APP_CATEGORIES[0].name == "Google Workspace & Google Cloud"
@@ -164,7 +164,7 @@ class TestCrewaiPlatformTools(unittest.TestCase):
with patch.dict("os.environ", {}, clear=True):
with self.assertRaises(ValueError) as context:
CrewaiPlatformTools(apps=["github"])
assert "No platform integration token found" in str(context.exception)
assert "CREWAI_PLATFORM_INTEGRATION_TOKEN" in str(context.exception)
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
@patch(
@@ -350,7 +350,10 @@ def test_clipper_client_executes_without_deployment_instance_uuid(
@patch.dict(
"os.environ",
{"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id"},
{
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
"CREWAI_ENTERPRISE_ACTION_AUTH_TOKEN": "ignored-token",
},
clear=True,
)
@patch(