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"