From d5ae34780131f1797c7c870df0feeafacd3dbbed Mon Sep 17 00:00:00 2001 From: Renato Byrro Date: Fri, 18 Jul 2025 11:22:21 -0300 Subject: [PATCH] Toolkit docs generator command for Arcade CLI (#414) --- libs/arcade-cli/arcade_cli/main.py | 71 +++ .../arcade_cli/toolkit_docs/__init__.py | 87 +++ .../arcade_cli/toolkit_docs/docs_builder.py | 521 ++++++++++++++++++ .../arcade_cli/toolkit_docs/templates.py | 159 ++++++ .../arcade_cli/toolkit_docs/utils.py | 158 ++++++ .../cli/toolkit_docs/test_docs_builder.py | 57 ++ .../toolkit_docs/test_docs_builder_utils.py | 145 +++++ 7 files changed, 1198 insertions(+) create mode 100644 libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py create mode 100644 libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py create mode 100644 libs/arcade-cli/arcade_cli/toolkit_docs/templates.py create mode 100644 libs/arcade-cli/arcade_cli/toolkit_docs/utils.py create mode 100644 libs/tests/cli/toolkit_docs/test_docs_builder.py create mode 100644 libs/tests/cli/toolkit_docs/test_docs_builder_utils.py diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index 4f0c7699..f71e6afe 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -18,6 +18,7 @@ from rich.text import Text from tqdm import tqdm import arcade_cli.worker as worker +from arcade_cli import toolkit_docs from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login from arcade_cli.constants import ( CREDENTIALS_FILE_PATH, @@ -738,6 +739,76 @@ def dashboard( handle_cli_error("Failed to open dashboard", e, debug) +@cli.command(help="Generate Toolkit documentation", rich_help_panel="Tool Development") +def generate_toolkit_docs( + toolkit_name: str = typer.Option( + ..., "--toolkit-name", "-n", help="The name of the toolkit to generate documentation for." + ), + toolkit_dir: str = typer.Option( + ..., + "--toolkit-dir", + "-t", + help="The path to the toolkit root directory.", + ), + docs_dir: str = typer.Option( + ..., + "--docs-dir", + "-r", + help="The path to the documentation root directory.", + ), + docs_section: str = typer.Option( + "", + "--docs-section", + "-s", + help=( + "The section of the docs to generate documentation for. E.g. 'productivity', 'sales'. " + "Defaults to an empty string (generate the docs in the root of /pages/toolkits)" + ), + ), + openai_model: str = typer.Option( + "gpt-4o-mini", + "--openai-model", + "-m", + help=( + "A few parts of the documentation are generated using OpenAI API. " + "This argument controls which OpenAI model to use. " + "E.g. 'gpt-4o', 'gpt-4o-mini'." + ), + show_default=True, + ), + openai_api_key: str = typer.Option( + None, + "--openai-api-key", + "-o", + help="The OpenAI API key. If not provided, will get it from the `OPENAI_API_KEY` env var.", + ), + tool_call_examples: bool = typer.Option( + False, + "--tool-call-examples", + "-e", + help="Whether to generate tool call examples", + show_default=True, + ), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + toolkit_docs.generate_toolkit_docs( + console=console, + toolkit_name=toolkit_name, + toolkit_dir=toolkit_dir, + docs_dir=docs_dir, + docs_section=docs_section, + openai_model=openai_model, + openai_api_key=openai_api_key, + tool_call_examples=tool_call_examples, + debug=debug, + ) + + console.print( + f"Generated documentation for '{toolkit_name}' in '{docs_dir}'", + style="bold green", + ) + + @cli.callback() def main_callback( ctx: typer.Context, diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py b/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py new file mode 100644 index 00000000..2457930d --- /dev/null +++ b/libs/arcade-cli/arcade_cli/toolkit_docs/__init__.py @@ -0,0 +1,87 @@ +from functools import partial + +import openai +from rich.console import Console + +from arcade_cli.toolkit_docs.docs_builder import ( + build_example_path, + build_examples, + build_reference_mdx_path, + build_toolkit_mdx, + build_toolkit_mdx_path, +) +from arcade_cli.toolkit_docs.utils import ( + get_all_enumerations, + get_list_of_tools, + print_debug_func, + read_toolkit_metadata, + resolve_api_key, + standardize_dir_path, + write_file, +) + + +def generate_toolkit_docs( + console: Console, + toolkit_name: str, + toolkit_dir: str, + docs_section: str, + docs_dir: str, + openai_model: str, + openai_api_key: str | None = None, + tool_call_examples: bool = True, + debug: bool = False, +) -> None: + openai.api_key = resolve_api_key(openai_api_key, "OPENAI_API_KEY") + + if not openai.api_key: + console.print( + "❌ Provide --openai-api-key argument or set the OPENAI_API_KEY environment variable", + style="red", + ) + return + + print_debug = partial(print_debug_func, debug, console) + + docs_dir = standardize_dir_path(docs_dir) + toolkit_dir = standardize_dir_path(toolkit_dir) + + print_debug("Reading toolkit metadata") + pip_package_name = read_toolkit_metadata(toolkit_dir) + + print_debug(f"Getting list of tools for {toolkit_name} from the local Python environment") + tools = get_list_of_tools(toolkit_name) + + print_debug(f"Found {len(tools)} tools") + + print_debug("Getting all enumerations potentially used in tool argument specs") + enums = get_all_enumerations(toolkit_dir) + + print_debug(f"Building /{toolkit_name.lower()}.mdx file") + reference_mdx, toolkit_mdx = build_toolkit_mdx( + toolkit_dir=toolkit_dir, + tools=tools, + docs_section=docs_section, + enums=enums, + pip_package_name=pip_package_name, + openai_model=openai_model, + ) + toolkit_mdx_path = build_toolkit_mdx_path(docs_section, docs_dir, toolkit_name) + write_file(toolkit_mdx_path, toolkit_mdx) + + if reference_mdx: + print_debug(f"Building /{toolkit_name.lower()}/reference.mdx file") + reference_mdx_path = build_reference_mdx_path(docs_section, docs_dir, toolkit_name) + write_file(reference_mdx_path, reference_mdx) + else: + print_debug("No Enums referenced by tool interfaces. Skipping reference.mdx file") + + if tool_call_examples: + print_debug("Building tool-call examples in Python and JavaScript") + examples = build_examples(print_debug, tools, openai_model) + + for filename, example in examples: + example_path = build_example_path(filename, docs_dir, toolkit_name) + write_file(example_path, example) + + print_debug(f"Done generating docs for {toolkit_name}") diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py b/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py new file mode 100644 index 00000000..adb1500c --- /dev/null +++ b/libs/arcade-cli/arcade_cli/toolkit_docs/docs_builder.py @@ -0,0 +1,521 @@ +import json +import os +import pprint +from enum import Enum +from typing import Any, Callable, cast + +import openai +from arcade_core import auth as auth_module +from arcade_core.schema import ( + ToolAuthRequirement, + ToolDefinition, + ToolInput, + ToolSecretRequirement, +) + +from arcade_cli.toolkit_docs.templates import ( + ENUM_ITEM, + ENUM_MDX, + ENUM_VALUE, + GENERIC_PROVIDER_CONFIG, + TABBED_EXAMPLES_LIST, + TABLE_OF_CONTENTS, + TABLE_OF_CONTENTS_ITEM, + TOOL_CALL_EXAMPLE_JS, + TOOL_CALL_EXAMPLE_PY, + TOOL_PARAMETER, + TOOL_SPEC, + TOOL_SPEC_SECRETS, + TOOLKIT_FOOTER, + TOOLKIT_FOOTER_OAUTH2, + TOOLKIT_HEADER, + TOOLKIT_PAGE, + WELL_KNOWN_PROVIDER_CONFIG, +) +from arcade_cli.toolkit_docs.utils import ( + clean_fully_qualified_name, + find_enum_by_options, + get_toolkit_auth_type, + is_well_known_provider, + pascal_to_snake_case, +) + + +def build_toolkit_mdx_path(docs_section: str, docs_root_dir: str, toolkit_name: str) -> str: + return os.path.join( + docs_root_dir, + "pages", + "toolkits", + docs_section, + f"{toolkit_name.lower()}.mdx", + ) + + +def build_reference_mdx_path(docs_section: str, docs_root_dir: str, toolkit_name: str) -> str: + return os.path.join( + docs_root_dir, + "pages", + "toolkits", + docs_section, + toolkit_name.lower(), + "reference.mdx", + ) + + +def build_example_path(example_filename: str, docs_root_dir: str, toolkit_name: str) -> str: + return os.path.join( + docs_root_dir, + "public", + "examples", + "integrations", + "toolkits", + toolkit_name.lower(), + example_filename, + ) + + +def build_toolkit_mdx( + toolkit_dir: str, + tools: list[ToolDefinition], + docs_section: str, + enums: dict[str, type[Enum]], + pip_package_name: str, + openai_model: str, + toolkit_header_template: str = TOOLKIT_HEADER, + toolkit_page_template: str = TOOLKIT_PAGE, +) -> tuple[str, str]: + sample_tool = tools[0] + toolkit_name = sample_tool.toolkit.name + toolkit_version = sample_tool.toolkit.version + auth_type = get_toolkit_auth_type(sample_tool.requirements.authorization) + toolkit_dirname = os.path.basename(os.path.dirname(toolkit_dir)) + + header = toolkit_header_template.format( + toolkit_title=toolkit_name, + description=generate_toolkit_description( + toolkit_name, + [(tool.name, tool.description) for tool in tools], + openai_model, + ), + pip_package_name=pip_package_name, + toolkit_dirname=toolkit_dirname, + auth_type=auth_type, + version=toolkit_version, + ) + table_of_contents = build_table_of_contents(tools) + footer = build_footer(toolkit_name, pip_package_name, sample_tool.requirements.authorization) + referenced_enums, tools_specs = build_tools_specs(tools, docs_section, enums) + reference_mdx = build_reference_mdx(toolkit_name, referenced_enums) if referenced_enums else "" + + return reference_mdx, toolkit_page_template.format( + header=header, + table_of_contents=table_of_contents, + tools_specs=tools_specs, + footer=footer, + ) + + +def build_reference_mdx( + toolkit_name: str, + referenced_enums: list[tuple[str, type[Enum]]], + enum_item_template: str = ENUM_ITEM, + enum_value_template: str = ENUM_VALUE, + enum_mdx_template: str = ENUM_MDX, +) -> str: + enum_items = "" + + for enum_name, enum_class in referenced_enums: + enum_items += enum_item_template.format( + enum_name=enum_name, + enum_values=build_enum_values( + enum_class=enum_class, + enum_value_template=enum_value_template, + ), + ) + + return enum_mdx_template.format( + toolkit_name=toolkit_name, + enum_items=enum_items, + ) + + +def build_enum_values( + enum_class: type[Enum], + enum_value_template: str = ENUM_VALUE, +) -> str: + enum_values = "" + for enum_member in enum_class: + enum_values += ( + enum_value_template.format( + enum_option_name=enum_member.name, + enum_option_value=enum_member.value, + ) + + "\n" + ) + return enum_values + + +def build_table_of_contents( + tools: list[ToolDefinition], + table_of_contents_item_template: str = TABLE_OF_CONTENTS_ITEM, + table_of_contents_template: str = TABLE_OF_CONTENTS, +) -> str: + tools_items = "" + + for tool in tools: + tools_items += table_of_contents_item_template.format( + tool_fully_qualified_name=clean_fully_qualified_name(tool.fully_qualified_name), + description=tool.description.split("\n")[0], + ) + + return table_of_contents_template.format(tool_items=tools_items) + + +def build_footer( + toolkit_name: str, + pip_package_name: str, + authorization: ToolAuthRequirement | None, + footer_template: str = TOOLKIT_FOOTER, + oauth2_footer_template: str = TOOLKIT_FOOTER_OAUTH2, + well_known_provider_config_template: str = WELL_KNOWN_PROVIDER_CONFIG, + generic_provider_config_template: str = GENERIC_PROVIDER_CONFIG, +) -> str: + if authorization and authorization.provider_type == "oauth2" and authorization.provider_id: + is_well_known = is_well_known_provider( + provider_id=authorization.provider_id, + auth_module=auth_module, + ) + config_template = ( + well_known_provider_config_template + if is_well_known + else generic_provider_config_template + ) + provider_configuration = config_template.format( + toolkit_name=toolkit_name, + provider_id=authorization.provider_id, + provider_name=authorization.provider_id.capitalize(), + ) + + return oauth2_footer_template.format( + pip_package_name=pip_package_name, + provider_configuration=provider_configuration, + ) + return footer_template.format(toolkit_name=toolkit_name, pip_package_name=pip_package_name) + + +def build_tools_specs( + tools: list[ToolDefinition], + docs_section: str, + enums: dict[str, type[Enum]], + tool_spec_template: str = TOOL_SPEC, + tool_parameter_template: str = TOOL_PARAMETER, + tool_spec_secrets_template: str = TOOL_SPEC_SECRETS, +) -> tuple[list[tuple[str, type[Enum]]], str]: + tools_specs = "" + referenced_enums = [] + for tool in tools: + tool_referenced_enums, tool_spec = build_tool_spec( + tool=tool, + docs_section=docs_section, + enums=enums, + tool_spec_template=tool_spec_template, + tool_parameter_template=tool_parameter_template, + tool_spec_secrets_template=tool_spec_secrets_template, + ) + tools_specs += tool_spec + referenced_enums.extend(tool_referenced_enums) + + return referenced_enums, tools_specs + + +def build_tool_spec( + tool: ToolDefinition, + docs_section: str, + enums: dict[str, type[Enum]], + tool_spec_template: str = TOOL_SPEC, + tool_parameter_template: str = TOOL_PARAMETER, + tool_spec_secrets_template: str = TOOL_SPEC_SECRETS, +) -> tuple[list[tuple[str, type[Enum]]], str]: + tabbed_examples_list = TABBED_EXAMPLES_LIST.format( + toolkit_name=tool.toolkit.name.lower(), + tool_name=pascal_to_snake_case(tool.name), + ) + referenced_enums, parameters = build_tool_parameters( + tool_input=tool.input, + docs_section=docs_section, + toolkit_name=tool.toolkit.name.lower(), + enums=enums, + tool_parameter_template=tool_parameter_template, + ) + + secrets = ( + build_tool_secrets( + secrets=tool.requirements.secrets, + template=tool_spec_secrets_template, + ) + if tool.requirements.secrets + else "" + ) + + return referenced_enums, tool_spec_template.format( + tool_fully_qualified_name=clean_fully_qualified_name(tool.fully_qualified_name), + tabbed_examples_list=tabbed_examples_list, + description=tool.description.split("\n")[0], + parameters=parameters, + secrets=secrets, + ) + + +def build_tool_secrets( + secrets: list[ToolSecretRequirement], + template: str = TOOL_SPEC_SECRETS, +) -> str: + if not secrets: + return "" + secret_keys_str = "`, `".join([secret.key for secret in secrets]) + return template.format(secrets=f"`{secret_keys_str}`") + + +def build_tool_parameters( + tool_input: ToolInput, + docs_section: str, + toolkit_name: str, + enums: dict[str, type[Enum]], + tool_parameter_template: str = TOOL_PARAMETER, +) -> tuple[list[tuple[str, type[Enum]]], str]: + referenced_enums = [] + parameters = "" + for parameter in tool_input.parameters: + schema = parameter.value_schema + if schema.enum: + enum_name, enum_class = find_enum_by_options(enums, schema.enum) + referenced_enums.append((enum_name, enum_class)) + param_definition = f"`Enum` [{enum_name}](/toolkits/{docs_section}/{toolkit_name}/reference#{enum_name})" + else: + if schema.inner_val_type: + param_definition = f"`{schema.val_type}[{schema.inner_val_type}]`" + else: + param_definition = f"`{schema.val_type}`" + + if parameter.required: + param_definition += ", required" + else: + param_definition += ", optional" + + parameters += ( + tool_parameter_template.format( + param_name=parameter.name, + definition=param_definition, + description=parameter.description, + ) + + "\n" + ) + + return referenced_enums, parameters + + +def build_examples( + print_debug: Callable, + tools: list[ToolDefinition], + openai_model: str, +) -> list[tuple[str, str]]: + examples = [] + for tool in tools: + print_debug(f"Generating tool-call examples for {tool.name}") + input_map = generate_tool_input_map(tool, openai_model) + fully_qualified_name = tool.fully_qualified_name.split("@")[0] + examples.append(( + f"{pascal_to_snake_case(tool.name)}_example_call_tool.py", + build_python_example(fully_qualified_name, input_map), + )) + examples.append(( + f"{pascal_to_snake_case(tool.name)}_example_call_tool.js", + build_javascript_example(fully_qualified_name, input_map), + )) + return examples + + +def build_python_example( + tool_fully_qualified_name: str, + input_map: dict[str, Any], + template: str = TOOL_CALL_EXAMPLE_PY, +) -> str: + input_map_str = pprint.pformat( + input_map, + indent=4, + width=100, + compact=False, + sort_dicts=False, + ) + input_map_str = "{\n " + input_map_str.lstrip("{ ").rstrip("}") + "\n}" # noqa: B005 + return template.format( + tool_fully_qualified_name=tool_fully_qualified_name, + input_map=input_map_str, + ) + + +def build_javascript_example( + tool_fully_qualified_name: str, + input_map: dict, + template: str = TOOL_CALL_EXAMPLE_JS, +) -> str: + return template.format( + tool_fully_qualified_name=tool_fully_qualified_name, + input_map=json.dumps(input_map, indent=2, ensure_ascii=False), + ) + + +def generate_toolkit_description( + toolkit_name: str, + tools: list[tuple[str, str]], + openai_model: str, +) -> str: + response = openai.chat.completions.create( + model=openai_model, + messages=[ + { + "role": "system", + "content": ( + "You are a helpful assistant. " + "When given a toolkit name and a list of tools, you will generate a " + "short, yet descriptive of the toolkit and the main actions a user " + "or LLM can perform with it.\n\n" + "As an example, here is the Asana toolkit description:\n\n" + "The Arcade Asana toolkit provides a pre-built set of tools for " + "interacting with Asana. These tools make it easy to build agents " + "and AI apps that can:\n\n" + "- Manage teams, projects, and workspaces.\n" + "- Create, update, and search for tasks.\n" + "- Retrieve data about tasks, projects, workspaces, users, etc.\n" + "- Manage task attachments.\n\n" + "And here is a JSON string with the list of tools in the Asana toolkit:\n\n" + "```json\n\n" + '[["AttachFileToTask", "Attaches a file to an Asana task\n\nProvide exactly ' + "one of file_content_str, file_content_base64, or file_content_url, never " + "more\nthan one.\n\n- Use file_content_str for text files (will be encoded " + "using file_encoding)\n- Use file_content_base64 for binary files like images, " + 'PDFs, etc.\n- Use file_content_url if the file is hosted on an external URL"], ' + '["CreateTag", "Create a tag in Asana"], ["CreateTask", "Creates a task in ' + "Asana\n\nThe task must be associated to at least one of the following: " + "parent_task_id, project, or\nworkspace_id. If none of these are provided and " + "the account has only one workspace, the task\nwill be associated to that " + "workspace. If the account has multiple workspaces, an error will\nbe raised " + 'with a list of available workspaces."], ["GetProjectById", "Get an Asana ' + 'project by its ID"], ["GetSubtasksFromATask", "Get the subtasks of a task"], ' + '["GetTagById", "Get an Asana tag by its ID"], ["GetTaskById", "Get a task by ' + 'its ID"], ["GetTasksWithoutId", "Search for tasks"], ["GetTeamById", "Get an ' + 'Asana team by its ID"], ["GetUserById", "Get a user by ID"], ["GetWorkspaceById", ' + '"Get an Asana workspace by its ID"], ["ListProjects", "List projects in Asana"], ' + '["ListTags", "List tags in an Asana workspace"], ["ListTeams", "List teams in ' + 'an Asana workspace"], ["ListTeamsTheCurrentUserIsAMemberOf", "List teams in ' + 'Asana that the current user is a member of"], ["ListUsers", "List users in ' + 'Asana"], ["ListWorkspaces", "List workspaces in Asana that are visible to the ' + 'authenticated user"], ["MarkTaskAsCompleted", "Mark a task in Asana as ' + 'completed"], ["UpdateTask", "Updates a task in Asana"]]\n\n```\n\n' + "Keep the description concise and to the point. The user will provide you with " + "the toolkit name and the list of tools. Generate the description according to " + "the instructions above." + ), + }, + { + "role": "user", + "content": ( + f"The toolkit name is {toolkit_name} and the list of tools is:\n\n" + "```json\n\n" + f"{json.dumps(tools, ensure_ascii=False)}\n\n" + "```\n\n" + "Please generate a description for the toolkit." + ), + }, + ], + temperature=0.0, + max_tokens=2048, + ) + + response_str = cast(str, response.choices[0].message.content) + return response_str.strip() + + +def generate_tool_input_map( + tool: ToolDefinition, + openai_model: str, + retries: int = 0, + max_retries: int = 3, +) -> dict[str, Any]: + interface_signature = build_tool_interface_signature(tool) + response = openai.chat.completions.create( + model=openai_model, + messages=[ + { + "role": "system", + "content": ( + "You are a helpful assistant expert in generating data for documenting " + "sample scripts to calling tools. A tool is a function that is used in " + "context of LLM tool-calling / function-calling.\n\n" + "When given a tool signature with typed arguments, " + "you must return exactly one JSON object (no markdown, no extra text) " + "where each key is an argument name, and each value is a sample value " + "for that argument that would make sense in a sample script to showcase " + "human software engineers how the tool may be called. Generate the " + "argument sample value based on its name and description\n\n" + "Not every single argument must always be present in the input map. " + "In some cases, the tool may require only one of two arguments to be " + "provided, for example. In such cases, an indication will be present " + "either/or in the tool description or the argument description. " + "Always follow such instructions when present in the tool interface.\n\n" + "Keep argument values as short as possible. Values don't have to always " + "be valid. For instance, for file content base64-encoded arguments, " + "you can use a short text or a placeholder like `[file_content]`, it is " + "not necessary that the value is a valid base64-encoded string.\n\n" + "Remember that you MUST RESPOND ONLY WITH A VALID JSON STRING, NO ADDED " + "TEXT. Your response will be json.load'ed, so it must be a valid JSON " + "string." + ), + }, + { + "role": "user", + "content": ( + "Here is a tool interface:\n\n" + f"{interface_signature}\n\n" + "Please provide a sample input map as a JSON object." + ), + }, + ], + temperature=0.0, + max_tokens=1024, + stop=["\n\n"], + ) + + response_str = cast(str, response.choices[0].message.content) + text = response_str.strip() + + try: + return cast(dict[str, Any], json.loads(text)) + except json.JSONDecodeError: + if retries < max_retries: + return generate_tool_input_map(tool, openai_model, retries + 1, max_retries) + raise ValueError(f"Failed to generate input map for tool {tool.name}: {text}") + + +def build_tool_interface_signature(tool: ToolDefinition) -> str: + args = [] + for arg in tool.input.parameters: + data: dict[str, Any] = { + "arg_name": arg.name, + "arg_description": arg.description, + "is_arg_required": arg.required, + "arg_type": arg.value_schema.val_type, + } + + if arg.value_schema.enum: + data["enum"] = { + "accepted_values": arg.value_schema.enum, + } + + args.append(data) + + return json.dumps({ + "tool_name": tool.name, + "tool_description": tool.description, + "tool_args": args, + }) diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py b/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py new file mode 100644 index 00000000..64615194 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/toolkit_docs/templates.py @@ -0,0 +1,159 @@ +TOOLKIT_PAGE = """{header} + +{table_of_contents} + +{tools_specs} + +{footer} +""" + +TOOLKIT_HEADER = """# {toolkit_title} + +import ToolInfo from "@/components/ToolInfo"; +import Badges from "@/components/Badges"; +import TabbedCodeBlock from "@/components/TabbedCodeBlock"; +import TableOfContents from "@/components/TableOfContents"; +import ToolFooter from "@/components/ToolFooter"; + + + + + +{description}""" + +TABLE_OF_CONTENTS = """## Available Tools + + + + + If you need to perform an action that's not listed here, you can [get in touch + with us](mailto:contact@arcade.dev) to request a new tool, or [create your + own tools](/home/build-tools/create-a-toolkit). +""" + +TABLE_OF_CONTENTS_ITEM = '\n ["{tool_fully_qualified_name}", "{description}"],' + +TOOL_SPEC = """## {tool_fully_qualified_name} + +
+{tabbed_examples_list} + +{description} + +**Parameters** + +{parameters} +{secrets} +""" + +TOOL_SPEC_SECRETS = """**Secrets** + +This tool requires the following secrets: {secrets} (learn how to [configure secrets](/home/build-tools/create-a-tool-with-secrets#supplying-the-secret)) +""" + +TABBED_EXAMPLES_LIST = """""" + +TOOL_PARAMETER = "- **{param_name}** ({definition}) {description}" + +TOOLKIT_FOOTER = """""" + +TOOLKIT_FOOTER_OAUTH2 = """## Auth + +{provider_configuration} + + +""" + +WELL_KNOWN_PROVIDER_CONFIG = "The Arcade {toolkit_name} toolkit uses the [{provider_name} auth provider](/home/auth-providers/{provider_id}) to connect to users' {toolkit_name} accounts. Please refer to the [{provider_name} auth provider](/home/auth-providers/{provider_id}) documentation to learn how to configure auth." + +GENERIC_PROVIDER_CONFIG = "The {toolkit_name} toolkit uses the Auth Provider with id `{provider_id}` to connect to users' {toolkit_name} accounts. In order to use the toolkit, you will need to configure the `{provider_id}` auth provider." + +TOOL_CALL_EXAMPLE_JS = """import {{ Arcade }} from "@arcadeai/arcadejs"; + +const client = new Arcade(); // Automatically finds the `ARCADE_API_KEY` env variable + +const USER_ID = "{{arcade_user_id}}"; +const TOOL_NAME = "{tool_fully_qualified_name}"; + +// Start the authorization process +const authResponse = await client.tools.authorize({{tool_name: TOOL_NAME}}); + +if (authResponse.status !== "completed") {{ + console.log(`Click this link to authorize: ${{authResponse.url}}`); +}} + +// Wait for the authorization to complete +await client.auth.waitForCompletion(authResponse); + +const toolInput = {input_map}; + +const response = await client.tools.execute({{ + tool_name: TOOL_NAME, + input: toolInput, + user_id: USER_ID, +}}); + +console.log(JSON.stringify(response.output.value, null, 2)); +""" + +TOOL_CALL_EXAMPLE_PY = """import json +from arcadepy import Arcade + +client = Arcade() # Automatically finds the `ARCADE_API_KEY` env variable + +USER_ID = "{{arcade_user_id}}" +TOOL_NAME = "{tool_fully_qualified_name}" + +auth_response = client.tools.authorize(tool_name=TOOL_NAME) + +if auth_response.status != "completed": + print(f"Click this link to authorize: {{auth_response.url}}") + +# Wait for the authorization to complete +client.auth.wait_for_completion(auth_response) + +tool_input = {input_map} + +response = client.tools.execute( + tool_name=TOOL_NAME, + input=tool_input, + user_id=USER_ID, +) +print(json.dumps(response.output.value, indent=2)) +""" + +ENUM_MDX = """# {toolkit_name} Reference + +Below is a reference of enumerations used by some tools in the {toolkit_name} toolkit: + +{enum_items} +""" + +ENUM_ITEM = """## {enum_name} + +{enum_values} +""" + +ENUM_VALUE = "- **{enum_option_name}**: `{enum_option_value}`" diff --git a/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py b/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py new file mode 100644 index 00000000..4e9cb910 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/toolkit_docs/utils.py @@ -0,0 +1,158 @@ +import importlib +import inspect +import os +import re +from enum import Enum +from pathlib import Path +from types import ModuleType + +from arcade_core.auth import AuthProviderType +from arcade_core.catalog import ToolCatalog +from arcade_core.schema import ToolAuthRequirement, ToolDefinition +from rich.console import Console + +from arcade_cli.utils import discover_toolkits + + +def print_debug_func(debug: bool, console: Console, message: str, style: str = "dim") -> None: + if not debug: + return + console.print(message, style=style) + + +def standardize_dir_path(dir_path: str) -> str: + dir_path = dir_path.rstrip("/") + "/" + return os.path.expanduser(dir_path) + + +def resolve_api_key(cli_input_value: str | None, env_var_name: str) -> str | None: + if cli_input_value: + return cli_input_value + elif os.getenv(env_var_name): + return os.getenv(env_var_name) + else: + return None + + +def write_file(path: str, content: str) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + + +def read_toolkit_metadata(toolkit_dir: str) -> str: + pyproject_path = os.path.join(toolkit_dir, "pyproject.toml") + with open(pyproject_path) as f: + content = f.read() + project_section_match = re.search(r"\[project\](.*?)(?=\n\[|$)", content, re.DOTALL) + if project_section_match: + project_content = project_section_match.group(1) + name_match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', project_content) + if name_match: + return name_match.group(1).strip() + + raise ValueError(f"Could not find package name in '{pyproject_path}'") + + +def pascal_to_snake_case(text: str) -> str: + return re.sub(r"(? list[ToolDefinition]: + tools = [] + toolkits = discover_toolkits() + + for toolkit in toolkits: + if toolkit.name.casefold() == toolkit_name.casefold(): + for module_name, module_tools in toolkit.tools.items(): + module = importlib.import_module(module_name) + for tool_name in module_tools: + tool_func = getattr(module, tool_name) + tool = ToolCatalog.create_tool_definition( + tool_func, toolkit.name, toolkit.version, toolkit.description + ) + tools.append(tool) + + if not tools: + raise ValueError(f"Tools not found for toolkit {toolkit_name}") + + return tools + + +def get_all_enumerations(toolkit_root_dir: str) -> dict[str, type[Enum]]: + enums = {} + toolkit_path = Path(toolkit_root_dir) + + for py_file in toolkit_path.rglob("*.py"): + if py_file.name == "__init__.py": + continue + + if ".venv" in py_file.parts or "venv" in py_file.parts: + continue + + module_name = py_file.stem + spec = importlib.util.spec_from_file_location(module_name, py_file) + if spec is None or spec.loader is None: + continue + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + for name, obj in inspect.getmembers(module): + if ( + name not in enums + and inspect.isclass(obj) + and issubclass(obj, Enum) + and obj is not Enum + ): + enums[name] = obj + + return enums + + +def get_toolkit_auth_type(requirement: ToolAuthRequirement | None) -> str: + if requirement is None: + return "" + elif requirement.provider_type == AuthProviderType.oauth2.value: + return 'authType="OAuth2"' + elif requirement.provider_type: + return f'authType="{requirement.provider_type}"' + return "" + + +def find_enum_by_options( + enums: dict[str, type[Enum]], options: list[str] +) -> tuple[str, type[Enum]]: + options_set = set(options) + for enum_name, enum_class in enums.items(): + enum_member_values = [member.value for member in enum_class] + if set(enum_member_values) == options_set: + return enum_name, enum_class + raise ValueError(f"No enum found for options: {options_set}") + + +def is_well_known_provider( + provider_id: str | None, + auth_module: ModuleType, +) -> bool: + if provider_id is None: + return False + + for _, obj in inspect.getmembers(auth_module, inspect.isclass): + if not issubclass(obj, auth_module.OAuth2) or obj is auth_module.OAuth2: + continue + try: + instance = obj() + except AttributeError: + continue + provider_id_matches = ( + hasattr(instance, "provider_id") and instance.provider_id == provider_id + ) + if provider_id_matches: + return True + + return False + + +def clean_fully_qualified_name(fully_qualified_name: str) -> str: + return fully_qualified_name.split("@")[0] diff --git a/libs/tests/cli/toolkit_docs/test_docs_builder.py b/libs/tests/cli/toolkit_docs/test_docs_builder.py new file mode 100644 index 00000000..1f78182b --- /dev/null +++ b/libs/tests/cli/toolkit_docs/test_docs_builder.py @@ -0,0 +1,57 @@ +import json + +from arcade_cli.toolkit_docs.docs_builder import build_javascript_example, build_python_example +from arcade_cli.toolkit_docs.templates import TOOL_CALL_EXAMPLE_JS, TOOL_CALL_EXAMPLE_PY + + +def test_build_javascript_example(): + tool_fully_qualified_name = "Toolkit.ToolName" + + input_map = { + "str_arg": "str_value", + "fake_bool_value": "true", + "fake_bool_phrase": "this is not a true boolean", + "int_arg": 123, + "bool_arg": True, + "list_arg": ["item1", "item2"], + "dict_arg": {"key1": "value1", "key2": "value2"}, + "list_of_bool": [True, False], + } + + response = build_javascript_example(tool_fully_qualified_name, input_map, TOOL_CALL_EXAMPLE_JS) + assert response == TOOL_CALL_EXAMPLE_JS.format( + tool_fully_qualified_name=tool_fully_qualified_name, + input_map=json.dumps(input_map, indent=2, ensure_ascii=False), + ) + + +def test_build_python_example(): + tool_fully_qualified_name = "Toolkit.ToolName" + + input_map = { + "str_arg": "str_value", + "fake_bool_value": "true", + "fake_bool_phrase": "this is not a true boolean", + "int_arg": 123, + "bool_arg": True, + "list_arg": ["item1", "item2"], + "dict_arg": {"key1": "value1", "key2": "value2"}, + "list_of_bool": [True, False], + } + + input_map_str = """{ + 'str_arg': 'str_value', + 'fake_bool_value': 'true', + 'fake_bool_phrase': 'this is not a true boolean', + 'int_arg': 123, + 'bool_arg': True, + 'list_arg': ['item1', 'item2'], + 'dict_arg': {'key1': 'value1', 'key2': 'value2'}, + 'list_of_bool': [True, False] +}""" + + response = build_python_example(tool_fully_qualified_name, input_map, TOOL_CALL_EXAMPLE_PY) + assert response == TOOL_CALL_EXAMPLE_PY.format( + tool_fully_qualified_name=tool_fully_qualified_name, + input_map=input_map_str, + ) diff --git a/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py b/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py new file mode 100644 index 00000000..f672c73b --- /dev/null +++ b/libs/tests/cli/toolkit_docs/test_docs_builder_utils.py @@ -0,0 +1,145 @@ +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest +from arcade_cli.toolkit_docs.utils import ( + clean_fully_qualified_name, + get_toolkit_auth_type, + is_well_known_provider, + pascal_to_snake_case, + read_toolkit_metadata, +) +from arcade_core.auth import Asana, AuthProviderType, Google, OAuth2, Slack +from arcade_core.schema import ToolAuthRequirement + + +@patch("arcade_cli.toolkit_docs.utils.open") +def test_read_toolkit_metadata(mock_open): + sample_pyproject_toml = """ +[build-system] +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "arcade_jira" +version = "0.1.2" +description = "Arcade.dev LLM tools for interacting with Atlassian Jira" +requires-python = ">=3.10" +dependencies = [ + "arcade-tdk>=2.0.0,<3.0.0", + "httpx>=0.27.2,<1.0.0", +] +[[project.authors]] +name = "Arcade" +email = "dev@arcade.dev" + +[project.optional-dependencies] +dev = [ + "arcade-ai[evals]>=2.0.0,<3.0.0", + "arcade-serve>=2.0.0,<3.0.0", + "pytest>=8.3.0,<8.4.0", + "pytest-cov>=4.0.0,<4.1.0", + "pytest-asyncio>=0.24.0,<0.25.0", + "pytest-mock>=3.11.1,<3.12.0", + "mypy>=1.5.1,<1.6.0", + "pre-commit>=3.4.0,<3.5.0", + "tox>=4.11.1,<4.12.0", + "ruff>=0.7.4,<0.8.0", +] + +# Use local path sources for arcade libs when working locally +[tool.uv.sources] +arcade-ai = {path = "../../", editable = true} +arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } +arcade-serve = { path = "../../libs/arcade-serve/", editable = true } + +[tool.mypy] +files = [ "arcade_jira/**/*.py",] +python_version = "3.10" +disallow_untyped_defs = "True" +disallow_any_unimported = "True" +no_implicit_optional = "True" +check_untyped_defs = "True" +warn_return_any = "True" +warn_unused_ignores = "True" +show_error_codes = "True" +ignore_missing_imports = "True" + +[tool.pytest.ini_options] +testpaths = [ "tests",] + +[tool.coverage.report] +skip_empty = true + +[tool.hatch.build.targets.wheel] +packages = [ "arcade_jira",] + """ + mock_open.return_value.__enter__.return_value.read.return_value = sample_pyproject_toml + assert read_toolkit_metadata("path/to/toolkits/jira") == "arcade_jira" + mock_open.assert_called_once_with("path/to/toolkits/jira/pyproject.toml") + + +@patch("arcade_cli.toolkit_docs.utils.open") +def test_read_toolkit_metadata_missing_project_name(mock_open): + sample_pyproject_toml = """ +[build-system] +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +version = "0.1.2" +description = "Arcade.dev LLM tools for interacting with Atlassian Jira" +requires-python = ">=3.10" +dependencies = [ + "arcade-tdk>=2.0.0,<3.0.0", + "httpx>=0.27.2,<1.0.0", +] +[[project.authors]] +name = "Arcade" +email = "dev@arcade.dev" + """ + mock_open.return_value.__enter__.return_value.read.return_value = sample_pyproject_toml + with pytest.raises(ValueError): + print("\n\n\n", read_toolkit_metadata("path/to/toolkits/jira")) + + +def test_pascal_to_snake_case(): + assert pascal_to_snake_case("PascalCase") == "pascal_case" + assert pascal_to_snake_case("PascalCase_abc") == "pascal_case_abc" + + +def test_get_toolkit_auth_type_none(): + assert get_toolkit_auth_type(requirement=None) == "" + + +def test_get_toolkit_auth_type_with_provider_type(): + requirement = ToolAuthRequirement(provider_type=AuthProviderType.oauth2.value) + assert get_toolkit_auth_type(requirement=requirement) == 'authType="OAuth2"' + + requirement = ToolAuthRequirement(provider_type="another_type") + assert get_toolkit_auth_type(requirement=requirement) == 'authType="another_type"' + + requirement = ToolAuthRequirement(provider_type="") + assert get_toolkit_auth_type(requirement=requirement) == "" + + +def test_is_well_known_provider_none(): + assert not is_well_known_provider(provider_id=None, auth_module=MagicMock(spec=ModuleType)) + + +def test_is_well_known_provider_matching_provider_id(): + mock_auth_module = MagicMock(spec=ModuleType) + + mock_auth_module.OAuth2 = OAuth2 + mock_auth_module.Google = Google + mock_auth_module.Slack = Slack + + assert is_well_known_provider(provider_id=Google().provider_id, auth_module=mock_auth_module) + assert is_well_known_provider(provider_id=Slack().provider_id, auth_module=mock_auth_module) + assert not is_well_known_provider(provider_id=Asana().provider_id, auth_module=mock_auth_module) + assert not is_well_known_provider(provider_id="another_provider", auth_module=mock_auth_module) + + +def test_clean_fully_qualified_name(): + assert clean_fully_qualified_name("Outlook.ListEmails") == "Outlook.ListEmails" + assert clean_fully_qualified_name("Outlook.ListEmails@1.0.0") == "Outlook.ListEmails"