diff --git a/.github/workflows/generate-toolkit-docs.md b/.github/workflows/generate-toolkit-docs.md index 4ef6fbdd4..d0a39c69b 100644 --- a/.github/workflows/generate-toolkit-docs.md +++ b/.github/workflows/generate-toolkit-docs.md @@ -4,8 +4,8 @@ This workflow regenerates toolkit JSON and opens a PR with the changes. It can b ## What it does -1. Builds the toolkit docs generator. -2. Generates toolkit JSON in `toolkit-docs-generator/data/toolkits` using the Engine tool metadata and summary endpoints. +1. Type-checks and tests the toolkit docs generator. +2. Generates toolkit JSON in `toolkit-docs-generator/data/toolkits` using the Engine tool metadata endpoint. 3. Syncs integrations sidebar navigation from the generated JSON. 4. Creates or updates a PR on the stable `automation/toolkit-docs` branch if any files changed. Later runs overwrite that open PR with the latest generated docs. diff --git a/.github/workflows/generate-toolkit-docs.yml b/.github/workflows/generate-toolkit-docs.yml index b342c21df..7048e67bb 100644 --- a/.github/workflows/generate-toolkit-docs.yml +++ b/.github/workflows/generate-toolkit-docs.yml @@ -1,7 +1,7 @@ name: Generate toolkit docs # Description: Generate toolkit JSON from Engine API, then sync sidebar navigation. # Flow: -# 1) Build the toolkit docs generator +# 1) Type-check and test the toolkit docs generator # 2) Generate toolkit JSON into toolkit-docs-generator/data/toolkits # 3) Sync integrations sidebar _meta.tsx from toolkit-docs-generator/data/toolkits # 4) Create or update a PR if changes were produced @@ -10,6 +10,11 @@ on: repository_dispatch: types: [porter_deploy_succeeded] workflow_dispatch: + inputs: + pr_branch: + description: "Optional isolated branch for a verification PR" + required: false + type: string # 11:00 UTC = 3 AM PST / 4 AM PDT — late enough that DST drift doesn't matter. schedule: - cron: "0 11 * * *" @@ -48,9 +53,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build toolkit docs generator - run: pnpm build - working-directory: toolkit-docs-generator + - name: Validate toolkit docs generator + run: pnpm run toolkit-docs:check - name: Generate toolkit docs run: | @@ -96,22 +100,24 @@ jobs: with: token: ${{ secrets.DOCS_PUBLISHABLE_GH_TOKEN }} commit-message: "[AUTO] Adding MCP Servers docs update" - title: "[AUTO] Adding MCP Servers docs update" + title: ${{ inputs.pr_branch && '[TEST] Generated toolkit docs verification' || '[AUTO] Adding MCP Servers docs update' }} body: | - This PR was generated after a Porter deploy succeeded. + This PR was generated by the toolkit docs workflow. - Trigger: ${{ github.event_name }} + - Source ref: ${{ github.ref_name }} - Deploy env: ${{ github.event.client_payload.env || 'unknown' }} - Deploy SHA: ${{ github.event.client_payload.deploy_sha || 'unknown' }} - Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} # Stable branch so later runs update the open auto-PR instead of # opening a new one each time. create-pull-request force-pushes the # latest generated docs onto this branch. - branch: automation/toolkit-docs + base: ${{ inputs.pr_branch && github.ref_name || github.event.repository.default_branch || 'main' }} + branch: ${{ inputs.pr_branch || 'automation/toolkit-docs' }} delete-branch: true - name: Request team review - if: steps.cpr.outputs.pull-request-number != '' + if: steps.cpr.outputs.pull-request-number != '' && (github.event_name != 'workflow_dispatch' || inputs.pr_branch == '') continue-on-error: true run: gh pr edit ${{ steps.cpr.outputs.pull-request-number }} --add-reviewer ArcadeAI/engineering-tools-and-dx env: diff --git a/.github/workflows/llmstxt.yml b/.github/workflows/llmstxt.yml index e45a1cab0..3150be2df 100644 --- a/.github/workflows/llmstxt.yml +++ b/.github/workflows/llmstxt.yml @@ -9,12 +9,14 @@ on: - "app/en/**/*.mdx" - "app/en/**/_meta.tsx" - "scripts/generate-llmstxt.ts" + - "toolkit-docs-generator/data/toolkits/**" pull_request: types: [opened, synchronize, reopened] paths: - "app/en/**/*.mdx" - "app/en/**/_meta.tsx" - "scripts/generate-llmstxt.ts" + - "toolkit-docs-generator/data/toolkits/**" permissions: contents: write @@ -24,6 +26,9 @@ jobs: llmstxt: name: Generate LLMSTXT runs-on: ubuntu-latest + if: > + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) permissions: contents: write @@ -40,6 +45,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref || github.ref }} token: ${{ secrets.DOCS_PUBLISHABLE_GH_TOKEN }} + fetch-depth: 0 - name: Install dependencies run: npm install -g pnpm diff --git a/CLAUDE.md b/CLAUDE.md index d0d643b17..56088322c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Arcade documentation site built with Next.js + Nextra (App Router), using pnpm a ```bash pnpm dev # Local dev server (port 3000) -pnpm build # Full production build (toolkit-markdown → next build → pagefind) +pnpm build # Full Next.js production build pnpm lint # Lint with Ultracite (Biome-based) pnpm format # Auto-format with Ultracite pnpm test # Run all Vitest tests @@ -27,7 +27,7 @@ pnpm vitest run tests/broken-link-check.test.ts - **`app/_lib/`** — Data-fetching utilities (toolkit catalog, slug generation, static params). - **`app/api/`** — API routes (markdown export, toolkit-data, glossary). - **`toolkit-docs-generator/`** — Generates MCP toolkit documentation from server metadata JSON files in `toolkit-docs-generator/data/toolkits/`. -- **`scripts/`** — Build/CI scripts (Vale style fixes, redirect checking, pagefind indexing, i18n sync). +- **`scripts/`** — Build/CI scripts (Vale style fixes, redirect checking, llms.txt generation, i18n sync). - **`tests/`** — Vitest tests (broken links, internal link validation, sitemap, smoke tests). - **`lib/`** — Next.js utilities (glossary remark plugin, llmstxt plugin). - **`next.config.ts`** — Contains ~138 redirect rules. diff --git a/README.md b/README.md index 33d97c1d2..d6afafb16 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ The `vale:fix` command requires an API key in `.env.local`. Without one, you can - [STYLEGUIDE.md](./STYLEGUIDE.md) - Writing standards for voice, tone, and structure - [AGENTS.md](./AGENTS.md) - Instructions for AI assistants working on docs -## llms.txt Generation +## llms.txt generation -The project includes a Next.js plugin that automatically generates an `llms.txt` file following the [llms.txt specification](https://llmstxt.org/). This file helps LLMs understand and navigate the documentation. +The project includes a script that generates an `llms.txt` file following the [llms.txt specification](https://llmstxt.org/). This file helps LLMs understand and navigate the documentation. -**Automatic generation**: Runs during production builds (`pnpm build`) +**Automatic generation**: Runs in the `Generate LLMs.txt` GitHub workflow **Manual generation**: Run `pnpm llmstxt` to regenerate the file diff --git a/app/_components/algolia-search.tsx b/app/_components/algolia-search.tsx index f808c2e8d..81a6e273c 100644 --- a/app/_components/algolia-search.tsx +++ b/app/_components/algolia-search.tsx @@ -2,7 +2,7 @@ import { liteClient as algoliasearch } from "algoliasearch/lite"; import { Search } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Configure, Highlight, @@ -41,6 +41,64 @@ const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME; const searchClient = appId && searchKey ? algoliasearch(appId, searchKey) : null; +export const ALGOLIA_SEARCH_CONFIG = { + attributesToSnippet: ["content:20"], + distinct: true, + highlightPreTag: "__ais-highlight__", + highlightPostTag: "__/ais-highlight__", + hitsPerPage: 15, + snippetEllipsisText: "…", +}; +export const ALGOLIA_SEARCH_DEBOUNCE_MS = 150; + +type SearchStatus = "idle" | "loading" | "stalled" | "error"; +type SearchTimer = ReturnType; + +export function getSearchErrorMessage(status: SearchStatus): string | null { + return status === "error" + ? "Search failed. Check your connection and try again." + : null; +} + +type ScheduleSearchOptions = { + query: string; + search: (nextQuery: string) => void; + setTypedQuery: (nextQuery: string) => void; + currentTimer: SearchTimer | null; + delayMs?: number; +}; + +export function scheduleSearch({ + query, + search, + setTypedQuery, + currentTimer, + delayMs = ALGOLIA_SEARCH_DEBOUNCE_MS, +}: ScheduleSearchOptions): SearchTimer | null { + setTypedQuery(query); + if (currentTimer) { + clearTimeout(currentTimer); + } + if (!query.trim()) { + search(query); + return null; + } + return setTimeout(() => search(query), delayMs); +} + +export function searchResultsAreCurrent( + query: string, + resultsQuery: string, + status: SearchStatus +): boolean { + const normalizedQuery = query.trim(); + return ( + normalizedQuery.length > 0 && + status === "idle" && + resultsQuery.trim() === normalizedQuery + ); +} + function safeHref(url: string | undefined): string { if (!url) { return "/"; @@ -138,28 +196,107 @@ function SearchHit({ hit }: { hit: DocSearchRecord }) { ); } -function EmptyQuery() { - const { indexUiState } = useInstantSearch(); - if (indexUiState.query) { - return null; +function SearchResults({ query }: { query: string }) { + const { results, status } = useInstantSearch({ catchError: true }); + if (!query.trim()) { + return ( +

+ Start typing to search the docs… +

+ ); + } + + const errorMessage = getSearchErrorMessage(status); + if (errorMessage) { + return ( +

+ {errorMessage} +

+ ); + } + + if ( + !(results && searchResultsAreCurrent(query, results.query ?? "", status)) + ) { + return ( +

+ Searching… +

+ ); + } + + if (results.nbHits === 0) { + return ( +

+ No results for{" "} + "{results.query}" +

+ ); } + return ( -

- Start typing to search the docs… -

+ ( + + )} + /> ); } -function NoResults() { - const { results } = useInstantSearch(); - if (!results?.query || results.nbHits > 0) { - return null; - } +type SearchQueryHook = ( + query: string, + search: (nextQuery: string) => void +) => void; + +function SearchContent() { + const [typedQuery, setTypedQuery] = useState(""); + const searchTimerRef = useRef | null>(null); + const queryHook = useCallback((query, search) => { + searchTimerRef.current = scheduleSearch({ + query, + search, + setTypedQuery, + currentTimer: searchTimerRef.current, + }); + }, []); + + useEffect( + () => () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + }, + [] + ); + return ( -

- No results for{" "} - "{results.query}" -

+ <> + +
+ + +
+
+ +
+ ); } @@ -222,38 +359,7 @@ export function AlgoliaSearch() {
{searchClient && indexName ? ( - -
- - -
-
- - - ( - - )} - /> -
+
) : ( diff --git a/app/_components/toolkit-docs/components/documentation-chunk-renderer.tsx b/app/_components/toolkit-docs/components/documentation-chunk-renderer.tsx index 8ed9808ca..b404288ec 100644 --- a/app/_components/toolkit-docs/components/documentation-chunk-renderer.tsx +++ b/app/_components/toolkit-docs/components/documentation-chunk-renderer.tsx @@ -11,6 +11,7 @@ import { SignupLink } from "../../analytics"; import TabbedCodeBlock from "../../tabbed-code-block"; import TableOfContents from "../../table-of-contents"; import ToolFooter from "../../tool-footer"; +import { sortDocumentationChunks } from "../lib/documentation-chunks"; import type { DocumentationChunk, DocumentationChunkLocation, @@ -239,64 +240,13 @@ function ChunkContent({ chunk }: { chunk: DocumentationChunk }) { ); } -/** - * Default priority for chunks without an explicit priority - */ -const DEFAULT_CHUNK_PRIORITY = 100; - -/** - * Compare two chunks by header alphabetically. - * Returns a negative/positive number if both have headers, - * -1 if only a has a header, 1 if only b has a header, - * or null if neither has a header (fall through to next criterion). - */ -function compareByHeader( - a: DocumentationChunk, - b: DocumentationChunk -): number | null { - const headerA = (a.header ?? "").replace(HEADER_PREFIX_REGEX, "").trim(); - const headerB = (b.header ?? "").replace(HEADER_PREFIX_REGEX, "").trim(); - - if (headerA && headerB) { - return headerA.localeCompare(headerB); - } - if (headerA) { - return -1; - } - if (headerB) { - return 1; - } - return null; -} - /** * Sorts documentation chunks deterministically by: * 1. Priority (lower = earlier) * 2. Header alphabetically (for chunks with same priority) * 3. Content string (for chunks with same priority and no header) */ -export function sortChunksDeterministically( - chunks: readonly DocumentationChunk[] -): DocumentationChunk[] { - return [...chunks].sort((a, b) => { - const priorityDiff = - (a.priority ?? DEFAULT_CHUNK_PRIORITY) - - (b.priority ?? DEFAULT_CHUNK_PRIORITY); - - if (priorityDiff !== 0) { - return priorityDiff; - } - - const headerResult = compareByHeader(a, b); - if (headerResult !== null) { - return headerResult; - } - - // Finally, sort by content for stability. - // Some call sites pass lightweight chunk objects where content is optional. - return (a.content ?? "").localeCompare(b.content ?? ""); - }); -} +export const sortChunksDeterministically = sortDocumentationChunks; /** * DocumentationChunkRenderer diff --git a/app/_components/toolkit-docs/lib/documentation-chunks.ts b/app/_components/toolkit-docs/lib/documentation-chunks.ts new file mode 100644 index 000000000..19bed1a14 --- /dev/null +++ b/app/_components/toolkit-docs/lib/documentation-chunks.ts @@ -0,0 +1,53 @@ +import type { DocumentationChunk } from "../types"; + +const DEFAULT_CHUNK_PRIORITY = 100; +const HEADER_PREFIX_REGEX = /^#+\s*/; + +type SortableDocumentationChunk = Pick< + DocumentationChunk, + "header" | "priority" +> & + Partial>; + +function compareByHeader( + left: T, + right: T +): number | null { + const leftHeader = (left.header ?? "") + .replace(HEADER_PREFIX_REGEX, "") + .trim(); + const rightHeader = (right.header ?? "") + .replace(HEADER_PREFIX_REGEX, "") + .trim(); + + if (leftHeader && rightHeader) { + return leftHeader.localeCompare(rightHeader); + } + if (leftHeader) { + return -1; + } + if (rightHeader) { + return 1; + } + return null; +} + +export function sortDocumentationChunks( + chunks: readonly T[] +): T[] { + return [...chunks].sort((left, right) => { + const priorityDifference = + (left.priority ?? DEFAULT_CHUNK_PRIORITY) - + (right.priority ?? DEFAULT_CHUNK_PRIORITY); + if (priorityDifference !== 0) { + return priorityDifference; + } + + const headerDifference = compareByHeader(left, right); + if (headerDifference !== null) { + return headerDifference; + } + + return (left.content ?? "").localeCompare(right.content ?? ""); + }); +} diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 12d9cacbf..d0b34a39e 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -13,6 +13,9 @@ export function toIntegrationLink(toolkit: { category?: string | null; }): string { const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build an integration link for: ${toolkit.id}`); + } const category = toolkit.category ?? "others"; return `${INTEGRATIONS_BASE}/${category}/${slug}`; } @@ -47,7 +50,12 @@ export function resolveIndexToolkits( continue; } - const link = toIntegrationLink(toolkit); + let link: string; + try { + link = toIntegrationLink(toolkit); + } catch { + continue; + } const hasPage = validLinks.has(link); // A bare duplicate of a real "-api" toolkit: drop it; the real card stays. diff --git a/app/_lib/toolkit-data.ts b/app/_lib/toolkit-data.ts index f0299c74e..22904e967 100644 --- a/app/_lib/toolkit-data.ts +++ b/app/_lib/toolkit-data.ts @@ -65,14 +65,40 @@ const DEFAULT_DATA_DIR = join( const resolveDataDir = (options?: ToolkitDataOptions): string => options?.dataDir ?? process.env.TOOLKIT_DATA_DIR ?? DEFAULT_DATA_DIR; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + const isValidToolkitData = (parsed: unknown): parsed is ToolkitData => - typeof parsed === "object" && - parsed !== null && + isRecord(parsed) && "id" in parsed && ("label" in parsed || "name" in parsed) && "metadata" in parsed && - typeof (parsed as Record).metadata === "object" && - (parsed as Record).metadata !== null; + isRecord(parsed.metadata); + +const isToolkitIndexEntry = (value: unknown): value is ToolkitIndexEntry => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === "string" && + typeof value.label === "string" && + typeof value.version === "string" && + typeof value.category === "string" && + (value.type === undefined || typeof value.type === "string") && + typeof value.toolCount === "number" && + Number.isInteger(value.toolCount) && + value.toolCount >= 0 && + typeof value.authType === "string" + ); +}; + +const isToolkitIndex = (value: unknown): value is ToolkitIndex => + isRecord(value) && + typeof value.generatedAt === "string" && + typeof value.version === "string" && + Array.isArray(value.toolkits) && + value.toolkits.every(isToolkitIndexEntry); const readToolkitFile = async ( filePath: string @@ -106,9 +132,9 @@ const findToolkitDataBySlug = async ( const candidateSlug = getToolkitSlug({ id: data.id, docsLink: data.metadata?.docsLink, - }).toLowerCase(); + })?.toLowerCase(); - if (candidateSlug === slugKey) { + if (candidateSlug && candidateSlug === slugKey) { return data; } } @@ -148,17 +174,11 @@ export const readToolkitIndex = async ( const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); - // Basic runtime validation - ensure it's an object with required fields - if ( - typeof parsed !== "object" || - parsed === null || - !("toolkits" in parsed) || - !Array.isArray((parsed as { toolkits: unknown }).toolkits) - ) { + if (!isToolkitIndex(parsed)) { return null; } - return parsed as ToolkitIndex; + return parsed; } catch { return null; } diff --git a/app/_lib/toolkit-markdown.ts b/app/_lib/toolkit-markdown.ts index 1c4629c9a..8233f8cfb 100644 --- a/app/_lib/toolkit-markdown.ts +++ b/app/_lib/toolkit-markdown.ts @@ -1,4 +1,6 @@ +import { sortDocumentationChunks } from "@/app/_components/toolkit-docs/lib/documentation-chunks"; import type { + DocumentationChunk, ToolDefinition, ToolkitData, ToolParameter, @@ -13,6 +15,51 @@ import type { */ const JSON_INDENT = 2; +function documentationBlocks( + chunks: readonly DocumentationChunk[] | null | undefined, + location: DocumentationChunk["location"], + position: DocumentationChunk["position"] +): string[] { + const blocks: string[] = []; + const sorted = sortDocumentationChunks( + (chunks ?? []).filter( + (chunk) => chunk.location === location && chunk.position === position + ) + ); + + for (const chunk of sorted) { + const content = chunk.content.trim(); + if (!content) { + continue; + } + blocks.push( + chunk.type === "code" ? `\`\`\`text\n${content}\n\`\`\`` : content + ); + } + + return blocks; +} + +function sectionBlocks( + chunks: readonly DocumentationChunk[] | null | undefined, + location: DocumentationChunk["location"], + defaultBlock: string | null +): string[] { + const before = documentationBlocks(chunks, location, "before"); + const replacement = documentationBlocks(chunks, location, "replace"); + const after = documentationBlocks(chunks, location, "after"); + const hasReplacement = (chunks ?? []).some( + (chunk) => chunk.location === location && chunk.position === "replace" + ); + let content: string[] = []; + if (hasReplacement) { + content = replacement; + } else if (defaultBlock) { + content = [defaultBlock]; + } + return [...before, ...content, ...after]; +} + /** Collapse newlines and escape pipes so a value is safe inside a table cell. */ function cell(value: string | null | undefined): string { return (value ?? "") @@ -48,37 +95,52 @@ function exampleBlock(tool: ToolDefinition): string | null { function toolBlock(tool: ToolDefinition): string { const blocks: string[] = [`### ${tool.qualifiedName}`]; - if (tool.description) { - blocks.push(tool.description.trim()); - } - - const scopes = tool.auth?.scopes ?? []; - if (scopes.length > 0) { - blocks.push( - `**Required OAuth scopes:** ${scopes.map((s) => `\`${s}\``).join(", ")}` - ); - } + blocks.push( + ...sectionBlocks( + tool.documentationChunks, + "description", + tool.description?.trim() ?? null + ) + ); + + const parameterBlock = + tool.parameters && tool.parameters.length > 0 + ? `**Parameters**\n\n${[ + "| Name | Type | Required | Description |", + "| --- | --- | --- | --- |", + ...tool.parameters.map(parameterRow), + ].join("\n")}` + : "_No parameters._"; + blocks.push( + ...sectionBlocks(tool.documentationChunks, "parameters", parameterBlock) + ); const secrets = tool.secrets ?? []; - if (secrets.length > 0) { - blocks.push(`**Secrets:** ${secrets.map((s) => `\`${s}\``).join(", ")}`); - } - - if (tool.parameters && tool.parameters.length > 0) { - const rows = [ - "| Name | Type | Required | Description |", - "| --- | --- | --- | --- |", - ...tool.parameters.map(parameterRow), - ]; - blocks.push(`**Parameters**\n\n${rows.join("\n")}`); - } else { - blocks.push("_No parameters._"); - } + const secretsBlock = + secrets.length > 0 + ? `**Secrets:** ${secrets.map((secret) => `\`${secret}\``).join(", ")}` + : null; + blocks.push( + ...sectionBlocks(tool.documentationChunks, "secrets", secretsBlock) + ); - if (tool.output) { - const desc = tool.output.description ? ` — ${tool.output.description}` : ""; - blocks.push(`**Output:** \`${tool.output.type}\`${desc}`); - } + const scopes = tool.auth?.scopes ?? []; + const scopesBlock = + scopes.length > 0 + ? `**Required OAuth scopes:** ${scopes + .map((scope) => `\`${scope}\``) + .join(", ")}` + : null; + blocks.push(...sectionBlocks(tool.documentationChunks, "auth", scopesBlock)); + + const outputBlock = tool.output + ? `**Output:** \`${tool.output.type}\`${ + tool.output.description ? ` — ${tool.output.description}` : "" + }` + : null; + blocks.push( + ...sectionBlocks(tool.documentationChunks, "output", outputBlock) + ); const example = exampleBlock(tool); if (example) { @@ -90,19 +152,42 @@ function toolBlock(tool: ToolDefinition): string { export function toToolkitMarkdown(data: ToolkitData): string { const blocks: string[] = [`# ${data.label || data.id}`]; + const chunks = data.documentationChunks; if (data.description) { blocks.push(data.description.trim()); } + blocks.push(...documentationBlocks(chunks, "header", "before")); + blocks.push(...documentationBlocks(chunks, "description", "before")); + blocks.push(...documentationBlocks(chunks, "description", "after")); + blocks.push(...documentationBlocks(chunks, "header", "replace")); + blocks.push(...documentationBlocks(chunks, "header", "after")); if (data.summary) { blocks.push(data.summary.trim()); } + blocks.push(...documentationBlocks(chunks, "auth", "before")); + blocks.push(...documentationBlocks(chunks, "auth", "after")); + blocks.push( + ...documentationBlocks(chunks, "before_available_tools", "before") + ); + blocks.push( + ...documentationBlocks(chunks, "before_available_tools", "after") + ); + blocks.push(...documentationBlocks(chunks, "custom_section", "before")); + blocks.push(...documentationBlocks(chunks, "custom_section", "after")); const tools = data.tools ?? []; blocks.push(`## Tools (${tools.length})`); + blocks.push( + ...documentationBlocks(chunks, "after_available_tools", "before") + ); + blocks.push(...documentationBlocks(chunks, "after_available_tools", "after")); for (const tool of tools) { blocks.push(toolBlock(tool)); } + blocks.push(...documentationBlocks(chunks, "footer", "before")); + blocks.push(...documentationBlocks(chunks, "footer", "replace")); + blocks.push(...documentationBlocks(chunks, "footer", "after")); return `${blocks.join("\n\n")}\n`; } diff --git a/app/_lib/toolkit-slug.ts b/app/_lib/toolkit-slug.ts index 5ff34e21d..f8939604b 100644 --- a/app/_lib/toolkit-slug.ts +++ b/app/_lib/toolkit-slug.ts @@ -2,6 +2,7 @@ import type { Toolkit } from "@arcadeai/design-system"; const TOOLKIT_ID_NORMALIZER = /[^a-z0-9]+/g; const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; +const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; export type ToolkitSlugSource = { id: string; @@ -43,10 +44,14 @@ export function toKebabCase(value: string): string { const extractSlugFromPath = (path: string): string | null => { const segments = path.split("/").filter(Boolean); - return segments.at(-1) ?? null; + const slug = segments.at(-1); + return slug && SAFE_SLUG.test(slug) ? slug : null; }; -export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { +export function getToolkitSlug({ + id, + docsLink, +}: ToolkitSlugSource): string | null { if (docsLink) { try { const url = new URL(docsLink); @@ -62,5 +67,10 @@ export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { } } - return toKebabCase(id); + const kebabId = toKebabCase(id); + if (SAFE_SLUG.test(kebabId)) { + return kebabId; + } + const normalizedId = normalizeToolkitId(id); + return normalizedId || null; } diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 1e07c5c33..79e138464 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -31,6 +31,13 @@ export type ToolkitRouteEntry = { category: IntegrationCategory; }; +const sortToolkitRoutes = (routes: ToolkitRouteEntry[]): ToolkitRouteEntry[] => + routes.sort( + (left, right) => + left.category.localeCompare(right.category) || + left.toolkitId.localeCompare(right.toolkitId) + ); + const DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES: ToolkitCatalogEntry[] = DESIGN_SYSTEM_TOOLKITS.map((toolkit) => ({ id: toolkit.id, @@ -70,6 +77,9 @@ export function getToolkitCanonicalPath(toolkit: { }): string { const category = normalizeCategory(toolkit.category); const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build a canonical path for toolkit: ${toolkit.id}`); + } return `/en/resources/integrations/${category}/${slug}`; } @@ -118,6 +128,9 @@ const listToolkitRoutesFromDataDir = async (options?: { id: parsed.id, docsLink: parsed.metadata?.docsLink, }); + if (!slug) { + continue; + } const category = normalizeCategory(parsed.metadata?.category); unique.set(slug, { toolkitId: slug, category }); } catch { @@ -125,7 +138,7 @@ const listToolkitRoutesFromDataDir = async (options?: { } } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); }; const resolveToolkitRoute = async ( @@ -152,6 +165,9 @@ const resolveToolkitRoute = async ( id: toolkit.id, docsLink: data?.metadata?.docsLink ?? catalogEntry?.docsLink, }); + if (!slug) { + return null; + } // JSON file is the source of truth for category. The generator is responsible // for writing the correct value; the design system catalog and index.json are // only used as a last resort when the JSON is missing. @@ -192,7 +208,7 @@ export async function listToolkitRoutes(options?: { unique.set(route.toolkitId, route); } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); } export async function getToolkitStaticParamsForCategory( diff --git a/app/en/get-started/setup/connect-arcade-docs/page.mdx b/app/en/get-started/setup/connect-arcade-docs/page.mdx index 3fa170e18..2f5ba9a5c 100644 --- a/app/en/get-started/setup/connect-arcade-docs/page.mdx +++ b/app/en/get-started/setup/connect-arcade-docs/page.mdx @@ -5,14 +5,11 @@ description: "Learn how to speed up your development with agents in your IDEs" # Agentic development -Every page on the Arcade docs site serves as markdown. When an AI agent or coding assistant visits any docs URL, the site automatically returns `Content-Type: text/markdown` instead of HTML if: +Arcade publishes an [`llms.txt`](/llms.txt) index that agents can use to discover documentation pages. -- The request `User-Agent` header matches a known AI agent (Claude, ChatGPT, Cursor, etc.) -- The request includes the `Accept: text/markdown` header +Generated toolkit reference pages also provide complete Markdown through the **Copy page** action. This Markdown includes tool parameters, authentication requirements, outputs, examples, and custom documentation sections that load on demand in the rendered page. -This means you can point your agent directly at any docs page. No need to copy and paste or use the `llms.txt` file. The agent will receive well-formatted markdown out of the box. - -For example, you can tell your agent to visit `docs.arcade.dev/get-started/quickstarts/call-tool-agent` and it will automatically get the markdown version of that page. +Other documentation URLs serve HTML. Use `llms.txt` to give an agent the documentation index, or use **Copy page** when you need Markdown for a specific page. ## LLMs.txt diff --git a/app/sitemap.ts b/app/sitemap.ts index 45b6615cf..f149c268e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { MetadataRoute } from "next"; +import { listToolkitRoutes } from "./_lib/toolkit-static-params"; const SITE_URL = process.env.SITE_URL ?? "https://docs.arcade.dev"; const NORMALIZED_SITE_URL = SITE_URL.replace(/\/+$/, ""); @@ -43,11 +44,25 @@ async function collectRoutes(dir: string): Promise { return entries; } +async function collectToolkitRoutes(): Promise { + const routes = await listToolkitRoutes(); + return routes.map(({ category, toolkitId }) => ({ + url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, + })); +} + export default function sitemap(): Promise { if (!cachedRoutes) { - cachedRoutes = collectRoutes(APP_DIR).then((routes) => { - routes.sort((a, b) => a.url.localeCompare(b.url)); - return routes; + cachedRoutes = Promise.all([ + collectToolkitRoutes(), + collectRoutes(APP_DIR), + ]).then(([toolkitRoutes, authoredRoutes]) => { + const routesByUrl = new Map( + [...toolkitRoutes, ...authoredRoutes].map((route) => [route.url, route]) + ); + return [...routesByUrl.values()].sort((a, b) => + a.url.localeCompare(b.url) + ); }); } diff --git a/package.json b/package.json index 95d3942a9..7f93d177a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "lint": "pnpm exec ultracite check", "format": "pnpm exec ultracite fix", "prepare": "husky install", + "toolkit-docs:typecheck": "tsc --noEmit --project toolkit-docs-generator/tsconfig.json", + "toolkit-docs:test": "vitest run --config toolkit-docs-generator/vitest.config.ts", + "toolkit-docs:check": "pnpm run toolkit-docs:typecheck && pnpm run toolkit-docs:test", "translate": "pnpm dlx tsx scripts/i18n-sync/index.ts && pnpm format", "llmstxt": "pnpm dlx tsx scripts/generate-llmstxt.ts", "test": "vitest --run", diff --git a/public/llms.txt b/public/llms.txt index 1edc542c5..1882216b1 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,4 +1,4 @@ - + # Arcade @@ -70,7 +70,7 @@ Arcade delivers three capabilities. Enforce (Agent Authorization): deploy agents - [Add remote MCP servers](https://docs.arcade.dev/en/guides/mcp-gateways/add-remote-servers): This documentation page provides a step-by-step guide for users to register and connect a remote MCP server to their Arcade project, enabling the use of its tools within MCP Gateways and SDKs. Users will learn how to configure connection settings, manage tool visibility - [Add user authorization to your MCP tools](https://docs.arcade.dev/en/guides/create-tools/tool-basics/create-tool-auth): This documentation page guides users on how to implement user authorization in their custom MCP tools using Arcade, OAuth, and various auth providers, specifically illustrating the process with a Reddit integration example. Users will learn to create tools that require OAuth for access, manage authorization - [Adding Resource Server Auth to Your MCP Server](https://docs.arcade.dev/en/guides/create-tools/secure-your-server/secure-your-mcp-server): This documentation page guides users on how to secure their HTTP MCP server using OAuth 2.1 Resource Server authentication, enabling tool-level authorization and secrets management. It outlines the prerequisites, benefits, and configuration steps necessary for implementing this security measure, ensuring that -- [Agentic development](https://docs.arcade.dev/en/get-started/setup/connect-arcade-docs): This documentation page provides guidance on utilizing agents in Integrated Development Environments (IDEs) to enhance development efficiency by accessing well-formatted markdown documentation directly from the Arcade site. It explains how AI agents can retrieve content without manual copying and introduces the LLM +- [Agentic development](https://docs.arcade.dev/en/get-started/setup/connect-arcade-docs): Documentation page - [Arcade Cloud infrastructure](https://docs.arcade.dev/en/guides/deployment-hosting/arcade-cloud): This documentation page provides an overview of the infrastructure supporting Arcade Cloud, detailing aspects such as data storage, protection, and sovereignty options. Users can learn about the management of their data, including retention policies and consent models for training data, as well as the - [Arcade Gateway Assistant](https://docs.arcade.dev/en/guides/mcp-gateways/create-via-ai): The Arcade Gateway Assistant documentation guides users in creating and managing MCP gateways through a chat interface using natural language commands. It outlines the prerequisites for setup, including creating an Arcade account and connecting to compatible chat clients, and provides step-by-step instructions for authentication and - [Arcade Glossary](https://docs.arcade.dev/en/resources/glossary): The Arcade Glossary provides definitions and explanations of key terms and concepts related to the Arcade platform, including agents, harnesses, MCP servers, and tools. This resource helps users understand the components and functionalities necessary for building, deploying, and managing applications that @@ -124,18 +124,8 @@ Arcade delivers three capabilities. Enforce (Agent Authorization): deploy agents - [On-premise MCP Servers](https://docs.arcade.dev/en/guides/deployment-hosting/on-prem): This documentation page guides users on how to deploy on-premises MCP servers within a hybrid architecture, allowing them to utilize Arcade's cloud infrastructure while maintaining control over their local environment. Users will learn to set up their MCP server, create secure tunnels for public - [Organize your MCP server and tools](https://docs.arcade.dev/en/guides/create-tools/tool-basics/organize-mcp-tools): This documentation page provides best practices for organizing your MCP server and tools, including how to define and import tools from separate files and other packages. Users will learn to maintain a clean project structure, enhance code readability, and effectively utilize decorators for tool management. - [page](https://docs.arcade.dev/en/resources/examples): This documentation page provides a collection of example applications that utilize Arcade's tools and MCP servers, showcasing various real-world scenarios and integration patterns. Users can explore complete source code and setup instructions for each example, enabling them to quickly understand and implement similar projects. -- [page](https://docs.arcade.dev/en/resources/integrations): This documentation page provides a comprehensive registry of all MCP Servers within the Arcade ecosystem, helping users identify and access available servers. It serves as a central resource for understanding the various MCP Server options. - [page](https://docs.arcade.dev/en/get-started/agent-frameworks/mastra): This documentation page guides users in building a TypeScript AI agent using the Mastra framework, enabling interaction with Gmail and Slack through Arcade tools. Users will learn to create an agent capable of reading emails, sending messages, and summarizing emails for Slack, - [page](https://docs.arcade.dev/en/get-started/agent-frameworks/langchain/auth-langchain-tools): This documentation page provides a step-by-step guide on how to authorize existing LangChain tools, such as the `GmailToolkit`, using the Arcade platform. It outlines the prerequisites, necessary package installations, and detailed instructions for initializing the Arcade client and completing -- [page](https://docs.arcade.dev/en/resources/integrations/customer-support/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users can learn about the components involved, such as dynamic parameters and metadata generation, to effectively utilize the MCP server in their projects. -- [page](https://docs.arcade.dev/en/resources/integrations/databases/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users will learn how to utilize the Page component along with dynamic parameters, metadata generation, and static parameters for effective server implementation. -- [page](https://docs.arcade.dev/en/resources/integrations/development/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users can learn about the components involved in the server's implementation, such as dynamic parameters and metadata generation. The page serves as a reference for understanding how to -- [page](https://docs.arcade.dev/en/resources/integrations/entertainment/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users will learn how to utilize the Page component and its associated methods for handling dynamic parameters and generating metadata. The page serves as a guide for developers working with -- [page](https://docs.arcade.dev/en/resources/integrations/payments/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users can learn about the components involved, such as dynamic parameters and metadata generation, to effectively implement and utilize the MCP server in their projects. -- [page](https://docs.arcade.dev/en/resources/integrations/productivity/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. It outlines the components available for importing, such as dynamic parameters and metadata generation, to assist users in effectively utilizing the MCP server. -- [page](https://docs.arcade.dev/en/resources/integrations/sales/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. It outlines the components available for importing, such as dynamic parameters and metadata generation, to assist users in effectively utilizing the MCP server. -- [page](https://docs.arcade.dev/en/resources/integrations/search/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. Users will learn how to utilize the MCP server through the provided imports and exports, including dynamic parameters and metadata generation. -- [page](https://docs.arcade.dev/en/resources/integrations/social/[toolkitId]): This documentation page provides an overview of the MCP server, including its generated documentation and key functionalities. It outlines the components available for importing, such as dynamic parameters and metadata generation, to assist users in implementing and utilizing the MCP server effectively. - [Providing useful tool errors](https://docs.arcade.dev/en/guides/create-tools/error-handling/useful-tool-errors): This documentation page provides guidance on effectively handling errors when building tools with Arcade MCP, emphasizing the importance of robust error management. It explains the automatic error adaptation process, outlines when to explicitly raise specific errors, and details common error scenarios encountered during tool development. - [RetryableToolError in Arcade](https://docs.arcade.dev/en/guides/create-tools/error-handling/retry-tools): This documentation page explains how to use the `RetryableToolError` in the Arcade Tool SDK to enhance tool call outcomes by providing additional context for input parameters. It outlines when to raise the error and includes an example of its application in a Slack messaging - [Run evaluations](https://docs.arcade.dev/en/guides/create-tools/evaluate-tools/run-evaluations): This documentation page provides guidance on using the `arcade evals` command to run evaluation suites across multiple providers and models, allowing users to execute, compare, and analyze evaluation results in various output formats. It covers features such as multi-provider support, @@ -152,10 +142,8 @@ Arcade delivers three capabilities. Enforce (Agent Authorization): deploy agents - [Setup Arcade with OpenAI Agents (TypeScript)](https://docs.arcade.dev/en/get-started/agent-frameworks/openai-agents/setup-typescript): This documentation page provides a comprehensive guide for setting up and building AI agents using the OpenAI Agents SDK with Arcade tools in TypeScript. It covers the integration process, including converting Arcade tools to the required format, handling authorization, and executing agent functions. - [Setup Arcade with OpenAI Agents SDK](https://docs.arcade.dev/en/get-started/agent-frameworks/openai-agents/setup-python): This documentation page guides users on how to set up and integrate Arcade tools within OpenAI Agents applications using the OpenAI Agents SDK. It covers the necessary prerequisites, provides step-by-step instructions for creating a CLI agent, and explains how to implement tool authorization - [Stytch](https://docs.arcade.dev/en/guides/user-sources/stytch): Documentation page -- [Tavily](https://docs.arcade.dev/en/resources/integrations/search/tavily): Documentation page - [The Arcade Registry](https://docs.arcade.dev/en/resources/registry-early-access): The Arcade Registry documentation provides an overview of a platform where developers can share and monetize their tools for agentic applications, similar to HuggingFace or Pypi. It explains how the registry integrates runtime metrics and user feedback to enhance tool development and usage - [Tool error handling](https://docs.arcade.dev/en/guides/tool-calling/error-handling): This documentation page provides guidance on effectively handling errors when using tools with Arcade's Tool Development Kit (TDK). It explains the error handling philosophy, outlines best practices, and offers code examples for managing output errors in various programming languages. Users will learn how -- [Tool feedback](https://docs.arcade.dev/en/resources/integrations/tool-feedback): Documentation page - [Tools](https://docs.arcade.dev/en/resources/tools): This documentation page provides an overview of Arcade's ecosystem for AI tools, enabling users to explore a catalog of pre-built integrations, create custom tools, and contribute their own tools to the community. It outlines the benefits of using Arcade tools, such as built - [Types of Tools](https://docs.arcade.dev/en/guides/create-tools/improve/types-of-tools): This documentation page explains the two types of tools offered by Arcade: Optimized and Unoptimized tools. It highlights the differences in design and functionality, emphasizing that Optimized tools are tailored for AI-powered chat interfaces to enhance performance and reliability, while Unoptimized - [Understanding `Context` and tools](https://docs.arcade.dev/en/guides/create-tools/tool-basics/runtime-data-access): This documentation page explains the purpose and usage of the `Context` class in Arcade's Tool framework, which provides tools with access to runtime capabilities and tool-specific data. Users will learn how to utilize the `Context` object to retrieve OAuth tokens, secrets @@ -172,3 +160,153 @@ Arcade delivers three capabilities. Enforce (Agent Authorization): deploy agents - [What are tools?](https://docs.arcade.dev/en/guides/tool-calling): This documentation page provides an overview of tool calling in language models, explaining how users can leverage tools to enhance the capabilities of AI models for tasks such as data retrieval and scheduling. It outlines the process of integrating tools with language models using the Arcade SDK, - [Why evaluate tools?](https://docs.arcade.dev/en/guides/create-tools/evaluate-tools/why-evaluate): This documentation page explains the importance of evaluating tools used by AI models to ensure accurate tool selection and parameter accuracy in production environments. It outlines the evaluation process, scoring components, and potential issues that can arise without proper assessments. Users can learn how to create - [Windows environment setup](https://docs.arcade.dev/en/get-started/setup/windows-environment): This documentation page provides step-by-step instructions for setting up the Arcade CLI on Windows, emphasizing the use of the `uv` package manager while offering fallback options with `pip`. It includes prerequisites, installation methods, validation steps, and troubleshooting tips to ensure + +## Integrations + +- [page](https://docs.arcade.dev/en/resources/integrations): This documentation page provides a comprehensive registry of all MCP Servers within the Arcade ecosystem, helping users identify and access available servers. It serves as a central resource for understanding the various MCP Server options. + +## Integrations - Customer Support + +- [Customer.io](https://docs.arcade.dev/en/resources/integrations/customer-support/customerio): The Customer.io toolkit lets LLMs interact with a Customer.io workspace via the Tracking and App APIs. It covers the full lifecycle of contacts, messaging, and analytics — from identifying people and recording events through sending transactional emails and triggering broadcasts. ## Capabilities - **Contact management**: Create, update, look up, search, and delete person profiles; resolve by email or ID; handle multi-match edge cases. - **Activity & message history**: Retrieve a person's timelin +- [Customer.io API](https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-api): **Customer.io API Toolkit** provides a set of tools that enable easy interaction with the Customer.io App API, enhancing marketing automation workflows. **Capabilities:** - Create, manage, and delete segments, collections, and newsletters. - Fetch detailed delivery and performance metrics for campaigns and transactional messages. - Manage customer data with CRUD operations on customer attributes and activities. - Retrieve insights on message performance, including link metrics and suppression st +- [Customer.io Pipelines API](https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-pipelines-api): **CustomerioPipelinesApi** enables seamless interaction with the Customer.io Track API, allowing LLMs to manage user data efficiently. This toolkit excels in automating user grouping, event tracking, and identity reconciliation, all essential for effective marketing and analytics. **Capabilities:** - Automate user grouping based on specified criteria. - Reconcile anonymous and identified user IDs for accurate tracking. - Identify users and assign traits for personalized experiences. - Send batch +- [Customer.io Track API](https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-track-api): Arcade Toolkit provides comprehensive tools for integrating with the Customer.io Track API, enabling effective management of customer profiles and interactions. This toolkit facilitates operations such as updating profiles, segmenting users, and logging events. **Capabilities** - Manage customer profiles, including updates, merges, and deletions. - Add and remove devices linked to customer profiles. - Batch process multiple requests for efficiency. - Report custom metrics and log events for cust +- [Freshdesk](https://docs.arcade.dev/en/resources/integrations/customer-support/freshdesk): **Freshdesk** is a customer support platform; this toolkit lets LLM agents read and write Freshdesk data — tickets, contacts, companies, agents, groups, and the knowledge base — without a human clicking through the UI. ## Capabilities - **Ticket lifecycle** — create, read, update, merge, reply publicly, and add private internal notes to tickets; search the queue by status, priority, requester, agent, group, tag, type, or recency. - **Contact & company management** — create or update contacts and +- [Freshservice API](https://docs.arcade.dev/en/resources/integrations/customer-support/freshservice-api): FreshserviceApi provides a toolkit that enables seamless interaction with the Freshservice API, facilitating various operations for managing IT service requests. This toolkit empowers developers to automate and streamline service desk actions effectively. **Capabilities** - Activate and manage Customer Satisfaction (CSAT) surveys. - Add, update, and delete tasks, notes, and details for tickets, projects, changes, and problems. - Retrieve comprehensive details for assets, vendors, agents, and req +- [Intercom API](https://docs.arcade.dev/en/resources/integrations/customer-support/intercom-api): Arcade Toolkit for Intercom enables integration with the Intercom API, facilitating seamless interactions for managing contacts, conversations, and data attributes. This toolkit offers a variety of tools to streamline communication processes, ensuring effective customer engagement and support. **Capabilities:** - Automate contact management with CRUD operations. - Streamline conversations by attaching contacts and companies. - Support data exports and status checks for tracking user interactions +- [PagerDuty API](https://docs.arcade.dev/en/resources/integrations/customer-support/pagerduty-api): PagerDuty's API toolkit empowers developers to seamlessly integrate and manage incident response workflows through a variety of automated tools. Designed for interaction with the PagerDuty API, it facilitates comprehensive insight into incident management and team coordination. **Capabilities:** - Integrates and manages incident workflows, actions, and teams efficiently. - Automates alert notifications, service integrations, and escalation policies. - Enables customization of incident types and +- [Pylon](https://docs.arcade.dev/en/resources/integrations/customer-support/pylon): The Arcade toolkit for Pylon enables seamless interaction with Pylon's issue tracking system and team management. Designed for developers, this toolkit facilitates issue management and user interaction through various tools. **Capabilities** - Manage Pylon issues including assigning and updating statuses. - Retrieve detailed information about teams, issues, and users. - Perform advanced searches using BM25 ranking and fuzzy matching techniques. - Efficiently list and filter contacts, issues, and +- [Pylon API](https://docs.arcade.dev/en/resources/integrations/customer-support/pylon-api): Pylon API provider: a toolkit that lets LLMs call Pylon endpoints to programmatically manage accounts, contacts, issues, knowledge bases, projects, users and training data. It exposes schema-driven endpoints for CRUD, batch operations, imports, searches and workflow actions. **Capabilities** - Unified CRUD and workflow operations across accounts, contacts, issues, projects, knowledge bases, tags and training data without calling individual endpoints manually. - Schema-first GET_REQUEST_SCHEMA / +- [Zendesk](https://docs.arcade.dev/en/resources/integrations/customer-support/zendesk): Arcade's Zendesk toolkit enables seamless integration with Zendesk's customer service platform, allowing developers to interact with tickets and knowledge base articles efficiently. **Capabilities** - Retrieve and manage ticket comments and statuses. - List and paginate through tickets for dynamic retrieval. - Search for Help Center articles using various parameters. - Fetch comprehensive user profiles and account information. **OAuth** - Auth Type: OAuth2 - Provider: Unknown - Scopes: read, tic + +## Integrations - Databases + +- [Clickhouse](https://docs.arcade.dev/en/resources/integrations/databases/clickhouse): ## Arcade ClickHouse Toolkit Provides tools to connect to and explore a ClickHouse database, enabling agents to introspect structure and execute read-only queries. ## Capabilities - **Schema discovery** — enumerate databases, retrieve a default schema representation, and list all tables in the connected instance. - **Table introspection** — fetch the full column structure of any named table before querying; required prior to any SELECT execution. - **Read-only query execution** — run parameteriz +- [MongoDB](https://docs.arcade.dev/en/resources/integrations/databases/mongodb): The MongoDB toolkit connects Arcade to a MongoDB instance, enabling agents and tools to explore, query, and aggregate data across databases and collections. ## Capabilities - **Discovery**: Enumerate all databases in a MongoDB instance and all collections within a database, establishing the context needed before any query runs. - **Schema inference**: Sample documents from a collection to infer field names and data types — essential since MongoDB is schema-less and must be introspected at runtim +- [Postgres](https://docs.arcade.dev/en/resources/integrations/databases/postgres): Postgres provider enables read-only exploration and querying of a PostgreSQL database within the Arcade toolkit. It provides schema discovery, table inspection, and constrained SELECT execution to build reliable, safe queries. **Capabilities** - Discover database schemas and table structures to drive query composition rather than guessing table/column names. - Execute read-only SELECT queries with enforced construction rules (explicit columns, ORDER BY, LIMIT/OFFSET, JOIN/HAVING/WHERE clauses). +- [Weaviate API](https://docs.arcade.dev/en/resources/integrations/databases/weaviate-api): Arcade Toolkit provides tools to interface directly with the Weaviate API, enabling developers to manage and organize data efficiently. This toolkit is essential for working with databases within Weaviate, making it easier to handle complex operations. ### Capabilities - Manage database users and roles, including assignment and permissions. - Perform batch operations for data objects and references, improving efficiency. - Create, update, and delete schemas and collections. - Monitor and manage +- [YugabyteDB](https://docs.arcade.dev/en/resources/integrations/databases/yugabytedb): YugabyteDB (Yugabytedb) Arcade toolkit lets LLMs inspect and query YugabyteDB instances in a safe, read-only manner. It enables schema discovery and executing SELECT-style queries with structured JSON output to support automated analysis and query generation. **Capabilities** - Discover database structure: list tables, columns, types, and row counts to inform query design. - Execute read-only SQL inside READ ONLY transactions to prevent accidental writes. - Return results as JSON arrays of row o + +## Integrations - Development + +- [Arcade Engine API](https://docs.arcade.dev/en/resources/integrations/development/arcade-engine-api): The Arcade Engine API toolkit provides developers with the ability to seamlessly integrate language models (LLMs) with the Arcade Engine API, enabling dynamic interactions and tool management. **Capabilities** - Authorize users and workers for specific tools. - Retrieve detailed information about tools, projects, and workers. - Monitor health statuses of the Arcade Engine and workers. - Execute tools with specified parameters and manage scheduled executions. - Manage user authentication connecti +- [Bright Data](https://docs.arcade.dev/en/resources/integrations/development/brightdata): Bright Data provides a developer toolkit for large-scale web search, crawling, and scraping, enabling reliable extraction of pages and structured data without getting blocked. It supports search queries, content-to-Markdown conversion, and configurable data feeds across many site types. Designed for integration into data pipelines and analytics workflows with parameterized feeds and output formats. **Capabilities** - Scale-resistant crawling and scraping with anti-blocking behavior for sustained +- [Cursor Agents](https://docs.arcade.dev/en/resources/integrations/development/cursor-agents): The Cursor Agents toolkit wraps the Cursor Cloud Agents API, enabling programmatic control of AI coding agents that operate on GitHub repositories inside Cursor's cloud environment. ## Capabilities - **Agent lifecycle management** — launch agents on repos or named environments, archive/unarchive reversibly, or permanently delete them; verify the active account before acting. - **Run control & monitoring** — start follow-up runs on existing agents, cancel active runs (with async confirmation via +- [Cursor Agents API](https://docs.arcade.dev/en/resources/integrations/development/cursor-agents-api): The CursorAgentsApi toolkit enables developers to interact with the Cursor Background Agents API, providing a structured way to manage and utilize background agents effectively. **Capabilities:** - Manage background agents with operations to create, delete, and retrieve their statuses. - Access conversation history for insights into agent interactions. - List all agents and their recommended models for tailored implementations. - Integrate with GitHub to access user repositories seamlessly. **OA +- [Datadog](https://docs.arcade.dev/en/resources/integrations/development/datadog): Datadog toolkit for Arcade provides LLM tools to search, aggregate, and inspect Datadog logs, APM spans, and traces programmatically. It enables AI agents to perform observability triage — identifying error patterns, latency trends, and trace-level diagnostics — directly against a Datadog account. ## Capabilities - **Log & span search**: Query log events and APM spans over configurable time windows using Datadog search syntax. - **Aggregation & bucketing**: Aggregate events or spans by count, pe +- [Datadog API](https://docs.arcade.dev/en/resources/integrations/development/datadog-api): The Arcade toolkit for DatadogApi empowers developers to interact seamlessly with the Datadog API to manage monitoring, analytics, and incident responses. This toolkit enhances the ability to automate workflows, streamline operations, and effectively manage cloud resources. **Capabilities**: - Efficiently manage Datadog integrations, monitor resources, and handle alerts. - Automate operational workflows, including on-call management and incident response. - Support configuration management for A +- [Daytona](https://docs.arcade.dev/en/resources/integrations/development/daytona): Arcade's Daytona toolkit lets LLMs provision and manage isolated sandboxes, run code and commands, operate persistent sessions, manage snapshots, and perform Git and file operations inside the sandbox. It streamlines dev-loop automation including port previews and SSH access. **Capabilities** - Full sandbox lifecycle: create (from snapshot or image), start, stop, archive, delete, resize, label, list regions, and configure auto-stop / auto-archive / auto-delete intervals. - File system and conten +- [E2B](https://docs.arcade.dev/en/resources/integrations/development/e2b): Arcade.dev provides the E2b toolkit, enabling developers to run Python code in a secure sandbox environment. This toolkit is ideal for generating visualizations and executing code snippets safely. **Capabilities** - Execute Python code in a controlled environment - Generate static matplotlib charts - Return outputs as base64 encoded images or direct results **OAuth** - No OAuth authentication required. Use API keys for access. **Secrets** - Manage API keys, such as the E2B_API_KEY, to authentica +- [Firecrawl](https://docs.arcade.dev/en/resources/integrations/development/firecrawl): Firecrawl is an Arcade.dev toolkit designed for efficient web scraping tasks. It leverages the Firecrawl API to perform various operations, enabling developers to streamline their data extraction processes. **Capabilities** - Perform asynchronous and synchronous website crawls - Retrieve crawl status and data for ongoing or recent tasks - Map an entire website from a single URL - Scrape specific URLs and return data in various formats - Cancel ongoing crawl jobs seamlessly **OAuth** No OAuth aut +- [Fly.io](https://docs.arcade.dev/en/resources/integrations/development/fly-io): Fly.io toolkit for Arcade enables LLMs to fully manage Fly.io infrastructure via the Fly.io API — apps, machines, volumes, networking, secrets, certificates, and deployments. ## Capabilities - **App & release management:** List, inspect, and deploy apps across organizations; browse full release history and roll out new container images to all machines. - **Machine lifecycle:** Create, start, stop, restart, and destroy individual machines; scale machine count or change VM size/memory fleet-wide. +- [GitHub](https://docs.arcade.dev/en/resources/integrations/development/github): # GitHub Toolkit The GitHub toolkit integrates Arcade with the GitHub API, enabling LLM-powered automation of repositories, pull requests, issues, code review, and project management across personal and organization scopes. ## Capabilities - **Repository & file operations:** Create branches, read/write/update files (with safe create-vs-overwrite modes), list org and user repositories, retrieve repository metadata, and track activity history. - **Pull requests & code review:** Create, update, mer +- [GitHub API](https://docs.arcade.dev/en/resources/integrations/development/github-api): Arcade Toolkit enables seamless interaction between LLMs and the GitHub API, allowing for efficient management and development of GitHub repositories. It provides developers with tools for automation, collaboration, and project management. ### Capabilities - Automate GitHub actions and workflows for repositories. - Manage branches, access permissions, and team collaborations. - Handle repository secrets, issues, pull requests, and releases. - Perform analytics on contributions, notifications, an +- [Math](https://docs.arcade.dev/en/resources/integrations/development/math): # Math Toolkit The Arcade Math toolkit provides a set of LLM-callable tools for performing mathematical operations, from basic arithmetic to statistical and number-theory calculations. ## Capabilities - **Arithmetic & rounding:** Add, subtract, multiply, divide, modulus, absolute value, ceiling, floor, and rounding. - **Powers, roots & logarithms:** Exponentiation, square root, and logarithm with configurable base. - **Statistics & list operations:** Average (mean), median, sum of a list, and su +- [PagerDuty](https://docs.arcade.dev/en/resources/integrations/development/pagerduty): PagerDuty Arcade toolkit lets LLMs interact with PagerDuty to query incidents, schedules, services, teams, and users. It enables programmatic inspection of incident state, activity feeds, on-call rosters, and configuration objects for automated diagnostics and workflow orchestration. **Capabilities** - Unified read access to incident lifecycle and activity feeds (triggers, acknowledgements, escalations, resolutions). - Query and filter lists (incidents, on-calls, schedules, services, teams) with +- [PostHog](https://docs.arcade.dev/en/resources/integrations/development/posthog): PostHog is a product analytics and experimentation platform. The Arcade PostHog toolkit lets agents query analytics, manage experiments, and curate insights directly from a PostHog project. **Capabilities** - Build and manage dashboards, insights, funnels, trends, and retention views. - Create and iterate on experiments, feature flags, and surveys with full CRUD support. - Inspect event and property definitions, investigate errors, and compare time periods. - Run ad-hoc HogQL or insight queries +- [PostHog API](https://docs.arcade.dev/en/resources/integrations/development/posthog-api): Arcade Toolkit provides developers with the tools to interact seamlessly with the PostHog API, enhancing their ability to manage projects efficiently. The toolkit allows for various API operations such as adding collaborators, managing dashboards, and executing saved queries. **Capabilities:** - Supports managing and updating dashboards and their settings. - Facilitates the addition of collaborators for effective project collaboration. - Enables querying and manipulating project data through sav +- [Vercel](https://docs.arcade.dev/en/resources/integrations/development/vercel): The Vercel toolkit lets you manage Vercel projects, deployments, domains, and environment variables programmatically via Arcade. It covers the full project lifecycle — from creation and configuration through deployment, promotion, rollback, and teardown. ## Capabilities - **Account & team discovery** — resolve the authenticated account, list accessible teams, and inspect team details (including billing plan) to establish the correct scope before making changes. - **Project management** — create, +- [Vercel API](https://docs.arcade.dev/en/resources/integrations/development/vercel-api): VercelApi is a comprehensive toolkit enabling developers to integrate with the Vercel API for efficient project and deployment management. It provides seamless access to various functionalities related to domain management, deployment operations, team collaboration, and environmental configurations. **Capabilities** - Manage project domains, environments, and associated configurations. - Facilitate user and team management within Vercel projects. - Accept project transfer requests and add member +- [Zoho Creator API](https://docs.arcade.dev/en/resources/integrations/development/zoho-creator-api): Zoho Creator is a low-code platform for building custom business applications. The Arcade Zoho Creator toolkit lets agents interact directly with the Creator REST API to manage records, reports, forms, and application metadata. **Capabilities** - Create, read, update, and delete records across Zoho Creator forms and reports. - Bulk-insert up to 200 records per call and run asynchronous bulk read jobs. - Inspect application metadata, form field schemas, report definitions, sections, and pages. - + +## Integrations - Entertainment + +- [Imgflip](https://docs.arcade.dev/en/resources/integrations/entertainment/imgflip): Arcade provides a toolkit for seamless interaction with Imgflip, empowering developers to create and manage custom memes efficiently. Users can leverage powerful tools to search, retrieve, and create memes from a vast database. **Capabilities** - Create personalized memes using various templates. - Retrieve a list of trending meme templates. - Search over 1 million templates based on specific queries. - Access detailed template information including popularity metrics. **Secrets** - Use IMGFLIP_ +- [Spotify](https://docs.arcade.dev/en/resources/integrations/entertainment/spotify): The Arcade toolkit for Spotify empowers developers to integrate with Spotify's music streaming services seamlessly. It enables a variety of playback functionalities and retrieval of music data. **Capabilities** - Control playback by adjusting position, pausing, and resuming tracks. - Access information on available devices and currently playing tracks. - Search the Spotify catalog with advanced filtering options. - Queue and play tracks or albums based on artist, name, or ID. **OAuth** - Auth is + +## Integrations - Payments + +- [Stripe](https://docs.arcade.dev/en/resources/integrations/payments/stripe): Arcade.dev provides a powerful toolkit for integrating with Stripe, enabling seamless management of billing, customer data, and payment processes. This toolkit simplifies common tasks, making it easier for developers to leverage Stripe's capabilities. ### Capabilities - Create and manage customers, products, and prices. - Generate invoices and billing portal sessions effortlessly. - Retrieve and list pertinent data such as invoices and payment intents. - Facilitate refunds and manage financial t +- [Stripe API](https://docs.arcade.dev/en/resources/integrations/payments/stripe_api): # Overview The StripeApi toolkit enables LLMs to directly interact with the Stripe API, facilitating seamless operations related to payments, account management, and customer interactions. ## Capabilities - Manage customer accounts, payment methods, and transactions. - Retrieve detailed financial data and reports. - Perform search operations for financial entities following Stripe's Search Query Language. - Delete accounts, invoices, and payment methods efficiently. ## OAuth This toolkit does no +- [Zoho Books API](https://docs.arcade.dev/en/resources/integrations/payments/zoho-books-api): Zoho Books API toolkit lets LLMs interact directly with Zoho Books to automate accounting workflows and manage invoices, payments, expenses, projects, tax settings, inventory, and organization configuration. It exposes CRUD, reconciliation, file attachment, email and approval workflows so agents can perform end-to-end bookkeeping and operational tasks. **Capabilities** - Automate transactional lifecycles (create/read/update/delete invoices, bills, payments, credit/debit notes). - Reconcile and c + +## Integrations - Productivity + +- [Airtable API](https://docs.arcade.dev/en/resources/integrations/productivity/airtable-api): ## Arcade Toolkit for Airtable API The Airtable API toolkit allows LLMs to seamlessly interact with Airtable, enabling the management of records, collaborators, and enterprise accounts efficiently. ### Capabilities - Manage Airtable bases, records, and tables with ease. - Collaborator management, including permissions and user roles within workspaces and interfaces. - Support for batch operations to manipulate multiple records and users. - Facilitate webhooks, comments, and audit log requests fo +- [Asana](https://docs.arcade.dev/en/resources/integrations/productivity/asana): Arcade offers a toolkit for seamless interaction with Asana, enabling developers to automate project management tasks. The toolkit supports various functionalities tailored for managing tasks, tags, and projects efficiently. **Capabilities** - Create, update, and manage tasks and subtasks within Asana. - Attach files to tasks in various formats. - Retrieve detailed information about projects, teams, and users. - List and create tags for organized task management. - Mark tasks as completed to str +- [Asana API](https://docs.arcade.dev/en/resources/integrations/productivity/asana-api): AsanaApi is a toolkit that enables integration with the Asana API, allowing LLMs to manage tasks, projects, goals, and custom fields effectively. **Capabilities:** - Create, update, delete, and retrieve records for tasks, projects, goals, and custom fields. - Manage task dependencies and followers with ease. - Retrieve detailed information about various entities, including custom field settings. - Utilize asynchronous operations for project instantiation and exports. - Efficiently handle permiss +- [Ashby](https://docs.arcade.dev/en/resources/integrations/productivity/ashby): Ashby is a recruiting platform; this toolkit lets Arcade agents read and write recruiting data — candidates, applications, jobs, notes, feedback, and interview stages — via the Ashby REST API. ## Capabilities - **Candidate management**: Search candidates by name/email, fetch full profiles, read and add plain-text notes, and pull a combined debrief bundle (profile + notes + scorecards) in one call. - **Application lifecycle**: List applications (filtered by job and/or status), move applications f +- [Ashby API](https://docs.arcade.dev/en/resources/integrations/productivity/ashby-api): AshbyApi enables seamless interactions with the Ashby recruitment platform, allowing LLMs to perform various recruitment tasks efficiently. ## Capabilities - Manage candidate profiles, including adding tags and assessments. - Facilitate interview scheduling and maintain interview records. - Create and update job openings and applications. - Archive and restore candidates, jobs, and departments swiftly. - Generate and manage reports on recruitment activities. ## Secrets - **API Key**: Use the `AS +- [Box API](https://docs.arcade.dev/en/resources/integrations/productivity/box-api): BoxApi provides tools enabling LLMs to interact directly with the Box API, facilitating various file and folder operations within a Box environment. ### Capabilities - Manage files, folders, and metadata. - Collaborate with users through invitations and shared links. - Monitor upload sessions, download statuses, and event logs. - Handle legal hold and retention policies. - Integrate with third-party applications like Slack and Teams. ### OAuth - **Provider**: Unknown - **Scopes**: None ### Secre +- [Calendly API](https://docs.arcade.dev/en/resources/integrations/productivity/calendly-api): This documentation details the Arcade toolkit for integrating with the Calendly API, enabling developers to efficiently manage scheduling events and invitees. **Capabilities** - Create and cancel scheduled events as well as generate custom share links. - Manage invitees by adding, removing, or marking their attendance status. - Retrieve user and organization-specific information such as availability and event types. - Set up webhook subscriptions for real-time notifications on event changes. **O +- [ClickUp](https://docs.arcade.dev/en/resources/integrations/productivity/clickup): The Arcade.dev Toolkit for ClickUp empowers developers to efficiently manage tasks, comments, and workspace structures within ClickUp through seamless API interactions. **Capabilities** - Create, update, and retrieve tasks and comments, enabling direct engagement with team tasks. - Conduct fuzzy searches for lists, folders, and members to enhance navigability. - Retrieve insights about workspaces and configure task statuses dynamically. - Manage team assignments effectively, facilitating team co +- [ClickUp API](https://docs.arcade.dev/en/resources/integrations/productivity/clickup-api): Arcade's ClickupApi toolkit enables seamless interaction with the ClickUp API, allowing developers to manage tasks, views, checklists, and more within their ClickUp workspaces. **Capabilities**: - Create, update, and delete tasks, lists, and views. - Add comments and checklists to tasks for effective collaboration. - Retrieve and manage time entries, goals, and custom fields. - Organize tasks by using tags and dependencies for better workflow management. **OAuth**: - Provider: ClickUp; Scopes: N +- [Confluence](https://docs.arcade.dev/en/resources/integrations/productivity/confluence): **Confluence** is an Atlassian wiki and documentation platform. This toolkit gives LLM agents the ability to read, write, search, and manage Confluence spaces and pages via the Arcade tool-calling interface. ## Capabilities - **Page lifecycle** — create, rename, retrieve (single or batch), update content, and explore child pages recursively with pagination. - **Space & cloud discovery** — list all spaces, fetch space details by ID or key, retrieve available Atlassian cloud instances, and identif +- [Dropbox](https://docs.arcade.dev/en/resources/integrations/productivity/dropbox): Arcade provides a toolkit for integrating with Dropbox, enabling seamless interactions with files stored in the cloud. Developers can leverage these tools for various file management capabilities. **Capabilities** - Download files directly from Dropbox. - List items in specified folders with efficient pagination. - Search for files and folders using customized criteria, while utilizing pagination to manage large result sets. **OAuth** - **Provider**: Dropbox - **Scopes**: files.content.read, fil +- [Figma](https://docs.arcade.dev/en/resources/integrations/productivity/figma): Figma Arcade toolkit lets LLMs interact with Figma files and team libraries via the Figma API, enabling programmatic inspection, export, and lightweight collaboration on designs. It supports fetching file trees, nodes, pages, components, styles, projects, exporting images, and managing comments. **Capabilities** - Inspect and traverse file and node trees with optional raw paint/style data for detailed analysis. - Export frames/nodes as temporary image assets and retrieve thumbnails for visual ou +- [Figma API](https://docs.arcade.dev/en/resources/integrations/productivity/figma-api): Arcade Toolkit for Figma API empowers LLMs to seamlessly interact with Figma projects. This toolkit provides a robust set of capabilities for managing design assets, comments, and webhooks within Figma. **Capabilities** - Manage comments, including adding, deleting, and reacting to them. - Create, update, and delete developer resources in bulk across multiple Figma files. - Access and manipulate Figma components, styles, and variables for efficient asset management. - Fetch analytics data and ve +- [Fireflies](https://docs.arcade.dev/en/resources/integrations/productivity/fireflies): Fireflies is an AI meeting-intelligence platform. This toolkit lets Arcade agents record, transcribe, search, and analyze meetings via the Fireflies GraphQL API. ## Capabilities - **Meeting capture & recording** — dispatch the Fireflies notetaker bot into a live meeting or upload an existing audio/video file for async transcription. - **Transcript & notes retrieval** — fetch verbatim, speaker-attributed transcripts with time-windowing, or pull AI-generated summaries, action items, keywords, and +- [Forkable](https://docs.arcade.dev/en/resources/integrations/productivity/forkable): **Forkable** is a workplace meal delivery service; this toolkit lets agents and users browse weekly menus, inspect dietary restrictions, and manage picks (swap or skip) entirely through code. ## Capabilities - **Weekly schedule visibility** — retrieve upcoming delivery days, current pick state (open / locked / delivered), and linked menu IDs in one call. - **Menu browsing** — fetch full menu details (sections, items, prices, modifiers, IDs) for one or more menus at once. - **Meal selection & mod +- [Gmail](https://docs.arcade.dev/en/resources/integrations/productivity/gmail): The Gmail toolkit provides Arcade LLM tools for interacting with Gmail via the Google Gmail API. It enables agents to read, compose, send, organize, and manage email on behalf of authenticated users. ## Capabilities - **Reading & searching mail:** List emails, threads, and drafts with built-in automated-mail filtering (no-reply patterns, non-primary categories); search or query threads; retrieve individual threads by ID; look up emails by header. - **Composing & drafting:** Create new drafts or +- [Google Calendar](https://docs.arcade.dev/en/resources/integrations/productivity/google-calendar): **Google Calendar toolkit** connects Arcade-powered LLMs to Google Calendar, enabling agents to read, create, modify, and delete calendar data on behalf of authenticated users. ## Capabilities - **Calendar discovery & user context** — list all accessible calendars and retrieve the authenticated user's profile, email, permissions, and environment details. - **Event querying** — list events within precise datetime ranges using independent lower/upper bounds on end and start times, supporting compl +- [Google Contacts](https://docs.arcade.dev/en/resources/integrations/productivity/google-contacts): The Google Contacts toolkit provides Arcade tools for managing personal contacts and querying Google Workspace organization directories via the Google People API. ## Capabilities - **Contact creation** — Create new contact records with any combination of name, email, and phone number fields. - **Multi-field contact search** — Look up contacts in the authenticated user's personal Google Contacts by name, email address, or phone number. - **Directory search** — Search the user's Google Workspace o +- [Google Docs](https://docs.arcade.dev/en/resources/integrations/productivity/google-docs): **Google Docs toolkit** connects Arcade to the Google Docs and Drive APIs, enabling LLMs to create, read, edit, search, and annotate Docs documents on behalf of authenticated users. ## Capabilities - **Document creation** — create blank documents or documents pre-populated with plain text or Markdown (headings, bold, italic, lists auto-formatted). - **Document reading** — retrieve full document content in DocMD format (block IDs, character indices, text styles, tab structure) or metadata-only (t +- [Google Drive](https://docs.arcade.dev/en/resources/integrations/productivity/google-drive): The Google Drive toolkit lets agents read, organize, share, and manage files in a user's Google Drive (including Shared Drives) via Arcade. It covers the full file lifecycle — discovery, access control, upload/download, and folder management. ## Capabilities - **File discovery & search** — search across all of Drive (names + content), retrieve folder/file trees, resolve bare IDs or full Google Drive/Workspace URLs, and batch-check accessibility before attempting reads. - **File & folder manageme +- [Google Sheets](https://docs.arcade.dev/en/resources/integrations/productivity/google-sheets): **Google Sheets toolkit** integrates Arcade with Google Sheets and Google Drive, enabling LLM-powered agents to read, write, comment on, audit, and search spreadsheets on behalf of authenticated users. ## Capabilities - **Access pre-flight & file discovery:** Batch-check spreadsheet accessibility before reading, generate Drive picker URLs to grant access, and search spreadsheets by title/content across a user's Drive. - **Read & inspect spreadsheets:** Retrieve workbook structure (tabs, charts, +- [Google Slides](https://docs.arcade.dev/en/resources/integrations/productivity/google-slides): Google Slides toolkit for Arcade provides LLM-callable tools that create, read, edit, and manage Google Slides presentations via the Google Slides and Drive APIs. ## Capabilities - **Presentation creation & editing:** Create new decks from scratch or from branded templates, apply atomic batches of edits (add/delete/restyle slides and elements, insert/replace text, reorder slides, duplicate objects, set speaker notes), and inspect addressable snapshots to discover object IDs and layout inventorie +- [Granola](https://docs.arcade.dev/en/resources/integrations/productivity/granola): Granola is a meeting intelligence platform that captures notes and transcripts from calls. The Arcade Granola toolkit gives agents read access to Granola's public Enterprise API for working with meeting metadata and transcripts. **Capabilities** - List meetings with optional date-range filters and cursor pagination. - Fetch full metadata for a meeting, including attendees and linked calendar event. - Retrieve verbatim transcripts with speaker labels and timestamps, optionally filtered by speaker +- [Jira](https://docs.arcade.dev/en/resources/integrations/productivity/jira): The Jira toolkit integrates Arcade with Atlassian Jira, enabling LLMs to manage issues, sprints, boards, users, attachments, and project metadata across Jira Cloud instances. ## Capabilities - **Issue lifecycle management** — Create, update, transition, search (parameterized or JQL), comment on, label, and attach files to issues; move issues between sprints and backlogs. - **Sprint & board operations** — List boards and their sprints (with date-range filtering), retrieve backlog and sprint issue +- [Linear](https://docs.arcade.dev/en/resources/integrations/productivity/linear): # Linear Toolkit The Linear toolkit lets LLMs interact with Linear's project management platform via Arcade, covering the full lifecycle of issues, projects, initiatives, cycles, and team workflows. ## Capabilities - **Issue management** — create, update, archive, transition workflow states, manage labels, link GitHub PRs/commits/issues, subscribe to notifications, and model relationships between issues (blocks, duplicates, related). - **Projects & milestones** — create, update, and archive proj +- [Luma API](https://docs.arcade.dev/en/resources/integrations/productivity/luma-api): LumaApi provides tools that facilitate LLMs in interacting seamlessly with the Luma API to manage events and user memberships. This toolkit allows developers to efficiently handle event creation, guest management, and membership features. **Capabilities** - Create, update, and manage events and their details. - Add and update guests, hosts, and members for events. - Generate and manage event tickets and coupons. - Import people and apply tags to streamline calendar management. **OAuth** - No OAu +- [Mailchimp API](https://docs.arcade.dev/en/resources/integrations/productivity/mailchimp-marketing-api): The MailchimpMarketingApi toolkit enables seamless integration with the Mailchimp Marketing API, allowing developers to manage marketing efforts effectively. It empowers users to execute various operations related to audience management, email campaigns, and e-commerce functionality. **Capabilities:** - Manage and update audience members, lists, and segments. - Create and distribute email campaigns with tracking capabilities. - Integrate e-commerce functionality including product management and +- [Microsoft Excel](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-excel): **Microsoft Excel toolkit** connects Arcade to Microsoft Excel via the Microsoft Graph API, enabling agents to read, write, audit, and collaborate on `.xlsx` workbooks stored in OneDrive for Business. ## Capabilities - **Workbook discovery & metadata** — search OneDrive for workbooks by keyword or folder, retrieve full workbook structure (worksheets, named ranges, tables, charts, protection state, used-range extents), and identify the current user's environment. - **Reading & analysis** — read w +- [Microsoft OneDrive](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-onedrive): Microsoft OneDrive toolkit for Arcade provides LLM-ready tools for managing files, folders, permissions, and sharing in a user's OneDrive via Microsoft Graph. ## Capabilities - **File & folder operations:** Create folders, copy (with async polling to completion), move/rename (single or batch), delete (single or batch), and download files (with byte-offset paging for large files). - **Browse & search:** List folder contents, list files shared with the current user, search the drive index, and ret +- [Microsoft Outlook Calendar](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-outlook-calendar): ## Microsoft Outlook Calendar Toolkit The Microsoft Outlook Calendar toolkit lets Arcade-powered LLMs interact with a user's Outlook Calendar via the Microsoft Graph API, enabling calendar reads, writes, and queries on behalf of authenticated users. ## Capabilities - **Calendar discovery & context:** List all calendars the user owns, shares, or has delegated access to; retrieve current user identity and calendar environment details. - **Event creation:** Create events in the user's default calen +- [Microsoft Outlook Mail](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-outlook-mail): The **Microsoft Outlook Mail** toolkit connects Arcade to the Microsoft Graph Mail API, enabling LLMs to read, compose, send, search, and organize emails in both personal and shared/delegated Outlook mailboxes. ## Capabilities - **Compose & send** — Create and immediately send emails, compose drafts (new, reply, or reply-all), update draft recipients/subject/body, and send any existing draft; returns `message_id` and `conversation_id` for immediate chaining. - **Read & retrieve** — Fetch a singl +- [Microsoft PowerPoint](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-powerpoint): ## Microsoft PowerPoint Toolkit The Microsoft PowerPoint toolkit provides Arcade LLM tools for creating and managing PowerPoint presentations stored in OneDrive via the Microsoft Graph API. ## Capabilities - **Presentation creation**: Create new presentations in OneDrive with a title slide; supports both small and large files (>4 MB via resumable upload sessions). - **Slide authoring**: Append slides with standard or two-column (`TWO_CONTENT`) layouts; titles and body content are optional to sup +- [Microsoft SharePoint](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-sharepoint): The Microsoft SharePoint toolkit integrates Arcade with Microsoft SharePoint via the Microsoft Graph API, enabling LLMs to read, write, and manage SharePoint content programmatically. ## Capabilities - **Site & drive navigation** — list, search, and retrieve sites, drives/document libraries, lists, list items, folders, and root drive contents; look up the current user's SharePoint environment. - **File & folder management** — create folders, copy, move, rename, and delete files or folders; check +- [Microsoft Word](https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-word): Arcade's Microsoft Word toolkit lets developers create, read, and update Word documents stored in OneDrive through Microsoft Graph. **Capabilities** - Create `.docx` documents with optional initial text, automatic `.docx` extension handling, folder targeting, and configurable filename conflict behavior: fail, rename, or replace. - Retrieve Word document metadata and Markdown content, or request metadata only. - Append text to existing `.docx` documents within the 4 MB upload limit. - Fetch authe +- [Miro API](https://docs.arcade.dev/en/resources/integrations/productivity/miro-api): Arcade Toolkit integrates with the Miro API, allowing developers to enhance collaborative functionalities within Miro boards. It empowers applications to add, update, or delete board items effectively while maintaining user engagement. **Capabilities** - Automated addition and management of various items on Miro boards. - Streamlined updates to existing board elements, such as shapes and text items. - Efficient retrieval of board details and user activities for oversight. - Integration with ente +- [Resend](https://docs.arcade.dev/en/resources/integrations/productivity/resend): The Resend toolkit integrates Resend's transactional email API with Arcade, enabling LLMs to send, schedule, inspect, and manage emails programmatically. ## Capabilities - **Send & schedule email**: Send transactional email immediately or queue it for future delivery by supplying a `scheduled_at` timestamp; returns the Resend-assigned `email_id`. - **Inspect delivery status**: Retrieve a single email by its Resend ID to check current delivery state and metadata. - **Manage scheduled email**: Can +- [SquareUp API](https://docs.arcade.dev/en/resources/integrations/productivity/squareup-api): SquareupApi is a toolkit designed for integrating with the Squareup API, empowering developers to facilitate seamless interactions with various Square services. This toolkit enables various operations, including managing customer data, handling invoices, and managing loyalty points. **Capabilities:** - Access detailed information about customers, orders, and transactions. - Perform CRUD operations on bookings, inventory, and payments. - Manage loyalty programs, including points and rewards. **OA +- [TickTick API](https://docs.arcade.dev/en/resources/integrations/productivity/ticktick-api): TickTick API toolkit enables LLMs to manage TickTick projects and tasks programmatically, performing creation, retrieval, updates, and deletions through the TickTick REST API. **Capabilities** - Full CRUD lifecycle for projects and tasks with support for properties like title, content, dates, reminders, subtasks, color, sort order, view mode, and kind. - Retrieve detailed project and task contexts to drive synchronization, reporting, and contextual recommendations. - Manage task state and schedu +- [Trello API](https://docs.arcade.dev/en/resources/integrations/productivity/trello-api): TrelloApi enables LLMs to interact with the Trello API, facilitating seamless task management and collaboration through automated actions. This toolkit provides a comprehensive set of tools for managing boards, cards, lists, and members within the Trello ecosystem. **Capabilities:** - Create, update, and delete Trello boards, cards, and lists. - Manage member assignments and roles across boards and workspaces. - Handle attachments, comments, and labels efficiently on Trello cards. - Set up webho +- [Xero API](https://docs.arcade.dev/en/resources/integrations/productivity/xero-api): XeroApi is a provider for interacting with the Xero accounting platform. The Arcade toolkit enables LLMs to call Xero endpoints to read and modify accounting records, attachments, reports, and histories. **Capabilities** - Query, create, update, and delete core accounting records (invoices, contacts, payments, items, journals) and manage attachments and change histories. - Generate and fetch financial reports and summaries (Profit & Loss, Balance Sheet, Trial Balance, aged receivables/payables) + +## Integrations - Sales + +- [Apollo](https://docs.arcade.dev/en/resources/integrations/sales/apollo): # Apollo Toolkit The Apollo toolkit lets LLMs interact with [Apollo.io](https://www.apollo.io/) sales intelligence, enabling account research, lead discovery, and contact enrichment workflows via the Apollo API. ## Capabilities - **Company intelligence**: Search Apollo's database by firmographic filters and enrich a company domain into detailed account data (industry, size, revenue, funding, location). - **People discovery & enrichment**: Search for people by role and employer profile, then enri +- [Attio](https://docs.arcade.dev/en/resources/integrations/sales/attio): Attio's Arcade toolkit enables LLMs to interact programmatically with Attio CRM, letting agents create, update, query, and manage records, lists, tasks, meetings, and call transcripts. It's optimized for workflow automation, idempotent upserts, and workspace-aware operations. **Capabilities** - CRUD and idempotent upsert semantics for records and list entries, with schema-aware field handling. - Discover and query object schemas and records with pagination and nested-field filtering. - Manage li +- [HubSpot](https://docs.arcade.dev/en/resources/integrations/sales/hubspot): Arcade Toolkit for HubSpot enables seamless integration and interaction with HubSpot's CRM functionalities, allowing developers to leverage various activities and data management tools for enhanced customer relationship management. **Capabilities** - Create, update, and manage companies, contacts, and deals within HubSpot. - Log and associate various engagement activities such as calls, emails, meetings, and more. - Retrieve detailed information about users, deals, and associated data effectivel +- [HubSpot Automation API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-automation-api): Arcade's HubSpot Automation API toolkit empowers developers to integrate LLMs with HubSpot's automation capabilities, facilitating seamless interaction with various automation workflows and sequences. **Capabilities** - Enroll contacts in sequences and fetch campaign details via automation tools. - Retrieve sequence statuses and details efficiently using contact IDs. - Manage batch actions and workflow ID mappings to optimize automation processes. - Access user-specific sequences to track indivi +- [HubSpot CMS API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-cms-api): The HubspotCmsApi toolkit enables developers to integrate and manage content on the HubSpot CMS through a comprehensive set of API tools. It provides powerful functionalities ranging from creating and managing blog posts, pages, and database rows to handling multi-language content effortlessly. **Capabilities**: - Efficiently create, update, and delete blog posts, tags, and pages. - Manage HubDB tables and their drafts with bulk operations. - Support multi-language content management across vari +- [HubSpot Conversations API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-conversations-api): HubspotConversationsApi enables seamless interactions with HubSpot's Conversations API, allowing for efficient management of conversation data. Developers can leverage this toolkit to execute various functionalities within HubSpot Conversations directly. **Capabilities** - Archive and manage conversation threads for data efficiency. - Create and retrieve details about channel accounts and inboxes. - Publish and manage custom channel messages. - Access detailed message history and conversation pa +- [HubSpot CRM API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-crm-api): # HubSpot CRM API Toolkit The HubSpot CRM API Toolkit enables seamless interactions with the HubSpot CRM system, providing tools that harness the extensive capabilities of the CRM ecosystem. ## Capabilities - Manage and automate CRM objects such as contacts, companies, deals, and more. - Perform batch operations for creating, updating, and retrieving records efficiently. - Facilitate associations between various CRM objects and manage their relationships. - Access detailed control over property +- [HubSpot Events API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-events-api): Arcade's HubspotEventsApi toolkit enables LLMs to interact seamlessly with the Hubspot Events API, streamlining event management for developers. It provides the necessary tools for creating, updating, retrieving, and deleting custom event definitions. ### Capabilities - Manage custom event definitions in Hubspot efficiently. - Access and update event properties with ease. - Retrieve information on existing events and their types. - Facilitate the cleanup of unnecessary event definitions. ### OAu +- [HubSpot Marketing API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-marketing-api): This documentation describes the HubspotMarketingApi toolkit, which allows LLMs to directly interact with the HubSpot Marketing API to manage and analyze marketing campaigns effectively. **Capabilities** - Create, update, and delete marketing campaigns, forms, emails, and events. - Manage budget items associated with campaigns. - Record and analyze event participation and contact interactions. - Retrieve detailed metrics and statistics for marketing activities. **OAuth** - Provider: Unknown - Sc +- [HubSpot Meetings API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-meetings-api): Arcade Toolkit for HubSpot Meetings API enables seamless interaction with HubSpot's scheduling features, allowing developers to automate meeting management directly through the platform. **Capabilities** - Schedule and manage meetings with calendar integration - Retrieve upcoming availability for booking - Get details for setting up meeting schedulers - List available meeting scheduling pages - Book meetings using HubSpot's APIs **OAuth** - Provider: Unknown - Scopes: Multiple CRM-related appoin +- [HubSpot Users API](https://docs.arcade.dev/en/resources/integrations/sales/hubspot-users-api): Arcade's HubspotUsersApi toolkit allows developers to interact seamlessly with the HubSpot Users API, enabling efficient management of user data and roles within HubSpot accounts. **Capabilities** - Create, retrieve, update, and remove users in HubSpot. - Fetch user roles and teams associated with specific accounts. - Manage user permissions effectively through targeted API calls. **OAuth** - This toolkit utilizes OAuth2 for authentication, requiring the following scopes: crm.objects.users.read, +- [Insightly](https://docs.arcade.dev/en/resources/integrations/sales/insightly): Insightly is a CRM platform; this toolkit gives Arcade agents full read/write access to contacts, leads, organizations, opportunities, projects, tasks, notes, and pipelines inside an Insightly account. ## Capabilities - **Record management** — Create and update core CRM objects (contacts, leads, organizations, opportunities, projects, tasks) with upsert-style tools that create on missing ID and update on presence. - **Lead conversion** — Convert qualified leads into contacts and organizations (w +- [Salesforce](https://docs.arcade.dev/en/resources/integrations/sales/salesforce): Arcade's Salesforce toolkit lets LLMs interact with Salesforce orgs to create, update, search, and convert CRM records, log activities, and fetch enriched relational data. It validates against org-configured picklists and returns contextual warnings and IDs to guide multi-step agent workflows. **Capabilities** - CRUD and lifecycle for leads, contacts, opportunities, and tasks, including the canonical lead-to-deal conversion with Contact/Account/Opportunity creation. - Rich account and opportunit + +## Integrations - Search + +- [Exa API](https://docs.arcade.dev/en/resources/integrations/search/exa-api): ExaApi provides a toolkit that enables LLMs to interact directly with the Exa.ai Search API, facilitating advanced data handling and search operations. **Capabilities** - Create, update, and delete various components such as Websets and enrichments. - Execute and manage search processes with options to cancel ongoing operations. - Set up automatic monitoring and notifications to keep data current. - Retrieve detailed information about websets, enrichments, and research requests. - Perform direct +- [Glean](https://docs.arcade.dev/en/resources/integrations/search/glean): # Glean Toolkit The Glean toolkit connects Arcade to a Glean enterprise deployment, exposing the Glean Client API so authorized users can search their organization's indexed content directly from Arcade workflows. ## Capabilities - **Enterprise search**: Query the user's Glean index and retrieve permission-filtered, ranked results scoped to the authenticated user. - **Paginated results**: Page through results using `offset` and `limit`; `has_next_page` signals additional pages, with offset pagin +- [Google Finance](https://docs.arcade.dev/en/resources/integrations/search/google_finance): GoogleFinance is a toolkit provided by Arcade.dev for accessing financial data through the Google Finance API. It enables developers to retrieve and analyze comprehensive stock information efficiently. **Capabilities** - Fetch historical stock price data over customizable time windows. - Retrieve current stock summaries, including price and recent trading movement. - Utilize API keys for secure access to financial data. **OAuth** - This toolkit uses an API key (SERP_API_KEY) for authentication. +- [Google Flights](https://docs.arcade.dev/en/resources/integrations/search/google_flights): **Google Flights toolkit** integrates Google Flights data into LLM workflows via SerpApi, enabling agents to search flights, resolve booking options, and look up airport codes. ## Capabilities - **Airport lookup** — Resolve city names, country names, or airport names to IATA codes (including metropolitan codes like NYC, LON) for use in flight searches. - **One-way & round-trip search** — Query Google Flights for one-way or round-trip itineraries in a single call; results include direct `google_f +- [Google Hotels](https://docs.arcade.dev/en/resources/integrations/search/google_hotels): Arcade.dev provides the GoogleHotels toolkit, enabling developers to efficiently retrieve hotel information through the Google Hotels API. This toolkit facilitates hotel searches, offering a streamlined method to access comprehensive hotel data. **Capabilities:** - Effortlessly search for hotels based on various parameters. - Retrieve detailed hotel information including availability and pricing. - Integrate smoothly with existing applications for enhanced user experience. **OAuth:** - No OAuth +- [Google Jobs](https://docs.arcade.dev/en/resources/integrations/search/google_jobs): Arcade.dev provides the GoogleJobs toolkit, enabling developers to access job postings directly from Google Jobs through SerpAPI. This toolkit streamlines job searches, making it efficient and effective to find relevant job listings. **Capabilities** - Seamless integration with Google Jobs for job postings retrieval. - Comprehensive search capabilities tailored to various job criteria. - Easy access to job data through a user-friendly API. **OAuth** - This toolkit does not require OAuth authenti +- [Google Maps](https://docs.arcade.dev/en/resources/integrations/search/google_maps): Arcade.dev provides a toolkit for integrating Google Maps functionalities, enabling developers to obtain directions seamlessly. This toolkit simplifies the process of accessing vital navigation data through an easy-to-use API. **Capabilities** - Retrieve directions between addresses or coordinates - Access detailed route information including estimated travel time and distance - Integrate with existing applications to enhance location-based services **OAuth** - This toolkit does not require OAut +- [Google News](https://docs.arcade.dev/en/resources/integrations/search/google_news): The Arcade toolkit for GoogleNews enables developers to retrieve the latest news articles through a seamless integration with Google News. This toolkit provides efficient access to current stories based on queries, ensuring users stay informed. ### Capabilities - Access to real-time news articles from Google News. - Query-based searching for specific topics or events. - Easy integration into applications for timely updates. ### OAuth No OAuth authentication is required, but API key usage is nece +- [Google Search](https://docs.arcade.dev/en/resources/integrations/search/google_search): Arcade.dev provides a toolkit for integrating Google search functionalities using its GoogleSearch tool. This enables developers to seamlessly fetch and utilize organic search results in their applications. **Capabilities** - Perform Google searches and retrieve organic results efficiently. - Integrate easily with existing applications and workflows. - Utilize powerful search algorithms to enhance user experiences. **OAuth** - No OAuth required; however, an API key is needed for access. **Secret +- [Google Shopping](https://docs.arcade.dev/en/resources/integrations/search/google_shopping): Arcade.dev offers a powerful toolkit for shopping via Google Shopping, enabling developers to seamlessly integrate product search functionality into their applications. This toolkit provides essential capabilities to enhance shopping experiences for users. **Capabilities** - Search for a variety of products on Google Shopping. - Seamlessly integrate product search into applications. - Enhance user engagement through relevant product displays. **OAuth** - No OAuth is required for this toolkit, bu +- [Tavily](https://docs.arcade.dev/en/resources/integrations/search/tavily): Documentation page +- [Walmart](https://docs.arcade.dev/en/resources/integrations/search/walmart): Walmart's Arcade toolkit empowers developers to seamlessly integrate product search functionalities and detailed product information retrieval from Walmart. This toolkit streamlines access to Walmart's extensive product catalog, enabling enhanced user experiences and effective e-commerce solutions. **Capabilities** - Retrieve comprehensive product details from Walmart's catalog - Efficiently search for products with relevant filters - Enhance e-commerce applications with real-time data - Facilit +- [Youtube](https://docs.arcade.dev/en/resources/integrations/search/youtube): Arcade.dev provides a toolkit for interacting with YouTube, enabling developers to search for videos and retrieve video details seamlessly. This toolkit simplifies tasks related to enhancing applications with YouTube content. **Capabilities** - Search for videos based on specific queries - Retrieve detailed information about YouTube videos - Supports integration of YouTube functionalities into applications - Allows quick access to video data for enhanced user experiences **OAuth** - No OAuth aut + +## Integrations - Social + +- [Discord Bot](https://docs.arcade.dev/en/resources/integrations/social/discord-bot): # Discord Bot Toolkit The Discord Bot toolkit lets an Arcade-powered agent act as a Discord bot — reading and writing messages, managing threads, handling reactions, and inspecting server structure — all authenticated via a bot token rather than OAuth. ## Capabilities - **Messaging**: Send, edit, delete, reply to, pin, and unpin messages across channels and threads; mention suppression (`@everyone`, `@here`, roles) is enforced on send, edit, and thread seed operations to prevent accidental serve +- [LinkedIn](https://docs.arcade.dev/en/resources/integrations/social/linkedin): Arcade.dev provides a toolkit for integrating with LinkedIn, enabling developers to streamline interactions with the platform's API. This toolkit allows for the creation of content directly on LinkedIn, enhancing user engagement and social sharing capabilities. **Capabilities** - Seamless integration with LinkedIn's API for enhanced social interactions. - Efficiently share content such as text posts to drive engagement. - Simplified authentication process through OAuth2, ensuring secure access t +- [Microsoft Teams](https://docs.arcade.dev/en/resources/integrations/social/microsoft-teams): ## Microsoft Teams Toolkit The Microsoft Teams toolkit provides Arcade LLM tools for interacting with Microsoft Teams via the Microsoft Graph API, enabling agents to read and send messages, manage chats and channels, and look up users and teams. ## Capabilities - **Messaging — chats:** Create chats, retrieve chat metadata and messages, send messages, and reply to messages in one-on-one or group chats; addressable by chat ID or user names/IDs. - **Messaging — channels:** Retrieve channel messages +- [Reddit](https://docs.arcade.dev/en/resources/integrations/social/reddit): Arcade.dev provides a powerful toolkit for integrating with Reddit, enabling developers to interact with Reddit's vast content and community features seamlessly. This toolkit allows for efficient data retrieval and engagement on the platform. **Capabilities** - Access subreddit data, including rules and content. - Retrieve and manipulate posts and comments. - Verify subreddit accessibility for authenticated users. - Simplify fetching multiple posts in one request. **OAuth** - Provider: Reddit - +- [Slack](https://docs.arcade.dev/en/resources/integrations/social/slack): The Slack toolkit integrates Arcade with Slack's API, enabling LLM agents to read, send, and manage Slack conversations, messages, and users on behalf of an authenticated user. ## Capabilities - **Conversation discovery & metadata**: List all conversations the user belongs to (channels, DMs, MPIMs) and retrieve detailed metadata for any conversation by ID, channel name, or participant identifiers. - **Message reading**: Fetch top-level messages from any conversation or drill into a specific thre +- [Slack API](https://docs.arcade.dev/en/resources/integrations/social/slack-api): The Arcade Toolkit for SlackApi provides a comprehensive interface for interacting with Slack's low-level API endpoints. Developers can utilize this toolkit to automate and enhance various administrative and communication tasks within Slack. **Capabilities:** - Create and manage conversations, channels, and user groups. - Handle messaging actions including sending, scheduling, and deleting messages. - Manage user presence and profiles. - Facilitate collaboration through call management and parti +- [Telegram](https://docs.arcade.dev/en/resources/integrations/social/telegram): # Telegram Toolkit The Telegram toolkit lets you build Arcade-powered agents that interact with Telegram bots — sending messages, polling for replies, delivering audio, and inspecting chats — using Telegram's Bot API and OpenAI TTS. ## Capabilities - **Bot identity & chat metadata**: Retrieve the bot's own profile and inspect any chat, group, or channel the bot belongs to (type, title, description, member count). - **Messaging**: Send text messages to any chat, group, or channel the bot has acce +- [X](https://docs.arcade.dev/en/resources/integrations/social/x): The X toolkit provides Arcade tools for interacting with the X (Twitter) platform, enabling LLMs to read and write tweets, manage lists, search content, and inspect user and Space metadata on behalf of an authenticated user. ## Capabilities - **Tweeting & threads:** Post, delete, like, unlike, retweet, undo-retweet, and quote tweets; compose multi-tweet threads with mid-thread resume support; attach polls and control reply permissions. - **Timeline & search:** Fetch the home timeline, @-mentions +- [Zoom](https://docs.arcade.dev/en/resources/integrations/social/zoom): Arcade.dev provides a toolkit for integrating with Zoom, enabling developers to leverage its functionalities in applications seamlessly. This toolkit allows for efficient access to Zoom meetings and invitations. **Capabilities** - Retrieve detailed information about specific Zoom meetings. - List upcoming meetings for a user within the next 24 hours. - Simplify access and interaction with Zoom's meeting management features. **OAuth** - Provider: Zoom - Scopes: meeting:read:invitation, meeting:re + +## Integrations - Tool Feedback + +- [Tool feedback](https://docs.arcade.dev/en/resources/integrations/tool-feedback): Documentation page diff --git a/scripts/generate-llmstxt.ts b/scripts/generate-llmstxt.ts index 2798ae084..a13995611 100644 --- a/scripts/generate-llmstxt.ts +++ b/scripts/generate-llmstxt.ts @@ -4,11 +4,17 @@ import path from "node:path"; import glob from "fast-glob"; import OpenAI from "openai"; import pc from "picocolors"; +import { readToolkitData } from "../app/_lib/toolkit-data"; +import { listToolkitRoutes } from "../app/_lib/toolkit-static-params"; type PageMetadata = { path: string; url: string; content: string; + summary?: { + title: string; + description: string; + }; }; type Section = { @@ -43,10 +49,21 @@ const MAX_CONTENT_LENGTH = 4000; const BATCH_DELAY_MS = 1000; const SHA_SHORT_LENGTH = 7; -// Initialize OpenAI client -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); +let openai: OpenAI | null = null; + +const getOpenAIClient = (): OpenAI => { + openai ??= new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + return openai; +}; + +const stripLlmsMetadata = (content: string): string => + content.replace(METADATA_REGEX, "").trimStart(); + +export const hasLlmsBodyChanged = ( + existingContent: string, + nextContent: string +): boolean => + stripLlmsMetadata(existingContent) !== stripLlmsMetadata(nextContent); /** * Gets the current git SHA @@ -154,9 +171,22 @@ async function extractExistingSummaries(): Promise< /** * Discovers all pages in the documentation */ -async function discoverPages(): Promise { +export async function discoverPages(): Promise { console.log(pc.blue("📄 Discovering pages from raw MDX...")); - return discoverMdxPages(); + const [mdxPages, toolkitPages] = await Promise.all([ + discoverMdxPages(), + discoverToolkitPages(), + ]); + const pagesByUrl = new Map( + [...mdxPages, ...toolkitPages].map((page) => [page.url, page]) + ); + const pages = [...pagesByUrl.values()]; + console.log( + pc.green( + `✓ Found ${mdxPages.length} authored pages and ${toolkitPages.length} generated toolkit pages` + ) + ); + return pages; } /** @@ -171,6 +201,9 @@ async function discoverMdxPages(): Promise { const pages: PageMetadata[] = []; for (const filePath of mdxFiles) { + if (filePath.includes("[")) { + continue; + } const fullPath = path.join(process.cwd(), filePath); const content = await fs.readFile(fullPath, "utf-8"); @@ -189,10 +222,42 @@ async function discoverMdxPages(): Promise { }); } - console.log(pc.green(`✓ Found ${pages.length} pages (raw MDX)`)); return pages; } +async function discoverToolkitPages(): Promise { + const routes = await listToolkitRoutes(); + const pages = await Promise.all( + routes.map(async ({ category, toolkitId }) => { + const data = await readToolkitData(toolkitId); + if (!data) { + return null; + } + + const title = data.label || data.id; + const sourceDescription = + data.summary ?? + data.description ?? + "Generated MCP server documentation."; + const description = sourceDescription + .replace(/\s+/g, " ") + .trim() + .slice(0, 500); + + return { + path: `toolkit-docs-generator/data/toolkits/${data.id.toLowerCase()}.json`, + url: `${BASE_URL}/en/resources/integrations/${category}/${toolkitId}`, + content: `# ${title}\n\n${sourceDescription}`, + summary: { title, description }, + } satisfies PageMetadata; + }) + ); + + return pages.filter( + (page): page is NonNullable<(typeof pages)[number]> => page !== null + ); +} + /** * Summarizes a page using OpenAI */ @@ -218,7 +283,7 @@ async function summarizePage( contentForSummary = contentForSummary.slice(0, MAX_CONTENT_LENGTH); - const response = await openai.chat.completions.create({ + const response = await getOpenAIClient().chat.completions.create({ model: "gpt-4o-mini", messages: [ { @@ -256,7 +321,7 @@ async function summarizePage( /** * Organizes pages into sections based on their path */ -function organizeSections( +export function organizeSections( pages: Array ): Section[] { const sectionMap = new Map(); @@ -276,6 +341,10 @@ function organizeSections( } else { sectionName = "Getting Started"; } + } else if (parts[0] === "resources" && parts[1] === "integrations") { + sectionName = parts[2] + ? `Integrations - ${formatSectionName(parts[2])}` + : "Integrations"; } else if (parts[0] === "mcp-servers") { // Organize MCP servers by category if (parts.length > 1) { @@ -443,6 +512,17 @@ function determinePagesToSummarize( const url = page.url; const existingSummary = existingSummaries.get(url); + if (page.summary) { + pagesToKeep.push({ ...page, ...page.summary }); + if ( + existingSummary?.title !== page.summary.title || + existingSummary.description !== page.summary.description + ) { + hasChanges = true; + } + continue; + } + // Check if this page's file was changed const isChanged = changedFiles.has(page.path); @@ -470,7 +550,13 @@ function determinePagesToSummarize( console.log( pc.yellow("⚠ No previous generation found, summarizing all pages") ); - pagesToSummarize.push(...pages); + for (const page of pages) { + if (page.summary) { + pagesToKeep.push({ ...page, ...page.summary }); + } else { + pagesToSummarize.push(page); + } + } hasChanges = true; // Always regenerate if no previous metadata } @@ -528,12 +614,6 @@ async function summarizePagesInBatches( async function main() { console.log(pc.bold(pc.blue("\n🚀 Generating llms.txt file...\n"))); - // Check for OpenAI API key - if (!process.env.OPENAI_API_KEY) { - console.error(pc.red("✗ OPENAI_API_KEY environment variable is required")); - process.exit(1); - } - try { // Step 0: Get current git SHA and check for previous generation const currentSha = getCurrentGitSha(); @@ -553,10 +633,15 @@ async function main() { const pages = await discoverPages(); // Step 2: Determine which pages need summarization and identify deleted pages - const { pagesToSummarize, pagesToKeep, hasChanges } = + const { pagesToSummarize, pagesToKeep } = determinePagesToSummarize(pages, previousMetadata, existingSummaries); // Step 3: Summarize changed/new pages using OpenAI + if (pagesToSummarize.length > 0 && !process.env.OPENAI_API_KEY) { + throw new Error( + "OPENAI_API_KEY is required when authored pages need new summaries." + ); + } const summarizedPages = await summarizePagesInBatches( pagesToSummarize, pagesToKeep @@ -569,26 +654,29 @@ async function main() { // Step 5: Generate llms.txt content console.log(pc.blue("\n✍️ Generating llms.txt content...")); - // Only update metadata if there are changes, otherwise keep previous metadata - const metadata: LlmsTxtMetadata = hasChanges - ? { - gitSha: currentSha, - generationDate: new Date().toISOString(), - } - : previousMetadata || { - gitSha: currentSha, - generationDate: new Date().toISOString(), - }; - const content = generateLlmsTxt(sections, metadata); + const currentMetadata: LlmsTxtMetadata = { + gitSha: currentSha, + generationDate: new Date().toISOString(), + }; + let content = generateLlmsTxt(sections, currentMetadata); + const existingContent = await fs + .readFile(OUTPUT_PATH, "utf-8") + .catch(() => null); + const bodyChanged = + existingContent === null || hasLlmsBodyChanged(existingContent, content); + if (!(bodyChanged || !previousMetadata)) { + content = generateLlmsTxt(sections, previousMetadata); + } + const outputChanged = existingContent === null || existingContent !== content; // Step 6: Write to file - await fs.writeFile(OUTPUT_PATH, content, "utf-8"); - if (hasChanges) { + if (outputChanged) { + await fs.writeFile(OUTPUT_PATH, content, "utf-8"); console.log(pc.green(`✓ Generated llms.txt at ${OUTPUT_PATH}`)); } else { console.log( pc.gray( - "✓ No changes detected, llms.txt unchanged (SHA and date preserved)" + "✓ No content changes detected; llms.txt metadata remains unchanged" ) ); } diff --git a/tests/algolia-search.test.ts b/tests/algolia-search.test.ts new file mode 100644 index 000000000..96c20eb38 --- /dev/null +++ b/tests/algolia-search.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ALGOLIA_SEARCH_CONFIG, + ALGOLIA_SEARCH_DEBOUNCE_MS, + getSearchErrorMessage, + scheduleSearch, + searchResultsAreCurrent, +} from "../app/_components/algolia-search"; + +describe("Algolia search configuration", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("uses the highlight markers expected by React InstantSearch", () => { + expect(ALGOLIA_SEARCH_CONFIG.highlightPreTag).toBe("__ais-highlight__"); + expect(ALGOLIA_SEARCH_CONFIG.highlightPostTag).toBe("__/ais-highlight__"); + }); + + test("debounces search requests while typing", () => { + vi.useFakeTimers(); + const search = vi.fn(); + const setTypedQuery = vi.fn(); + let timer = scheduleSearch({ + query: "g", + search, + setTypedQuery, + currentTimer: null, + }); + timer = scheduleSearch({ + query: "github", + search, + setTypedQuery, + currentTimer: timer, + }); + + expect(setTypedQuery).toHaveBeenLastCalledWith("github"); + expect(search).not.toHaveBeenCalled(); + vi.advanceTimersByTime(ALGOLIA_SEARCH_DEBOUNCE_MS); + expect(search).toHaveBeenCalledOnce(); + expect(search).toHaveBeenCalledWith("github"); + + scheduleSearch({ + query: "", + search, + setTypedQuery, + currentTimer: timer, + }); + expect(search).toHaveBeenLastCalledWith(""); + }); + + test("does not render stale results while a new query is pending", () => { + expect(searchResultsAreCurrent("slack", "github", "idle")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "loading")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "stalled")).toBe(false); + expect(searchResultsAreCurrent("slack", "slack", "idle")).toBe(true); + }); + + test("surfaces failed searches instead of leaving a loading state", () => { + expect(getSearchErrorMessage("error")).toBe( + "Search failed. Check your connection and try again." + ); + expect(getSearchErrorMessage("loading")).toBeNull(); + }); +}); diff --git a/tests/documentation-chunks.test.ts b/tests/documentation-chunks.test.ts new file mode 100644 index 000000000..038e8a921 --- /dev/null +++ b/tests/documentation-chunks.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { sortDocumentationChunks } from "../app/_components/toolkit-docs/lib/documentation-chunks"; +import type { DocumentationChunk } from "../app/_components/toolkit-docs/types"; + +describe("sortDocumentationChunks", () => { + test("supports lightweight chunks with optional content", () => { + const chunks = [ + { header: undefined }, + { header: undefined }, + { header: "## B" }, + { header: "## A" }, + ] as unknown as DocumentationChunk[]; + + expect(() => sortDocumentationChunks(chunks)).not.toThrow(); + expect( + sortDocumentationChunks(chunks).map((chunk) => chunk.header ?? null) + ).toEqual(["## A", "## B", null, null]); + }); +}); diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index de50d8f9d..fa4bc7996 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -99,6 +99,16 @@ describe("resolveIndexToolkits (logic)", () => { ).toHaveLength(1); expect(links.length).toBe(new Set(links).size); }); + + test("drops catalog entries whose IDs cannot form a route slug", () => { + const invalid = { + ...makeToolkit("---", "development", "invalid"), + id: "---", + docsLink: null, + }; + + expect(resolveIndexToolkits([invalid], new Set())).toEqual([]); + }); }); describe("integrations index links (live data)", () => { diff --git a/tests/llmstxt.test.ts b/tests/llmstxt.test.ts new file mode 100644 index 000000000..0060d5c54 --- /dev/null +++ b/tests/llmstxt.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + discoverPages, + hasLlmsBodyChanged, + organizeSections, +} from "../scripts/generate-llmstxt"; + +describe("llms.txt page discovery", () => { + test("expands generated toolkit routes instead of template URLs", async () => { + const pages = await discoverPages(); + const urls = pages.map(({ url }) => url); + + expect(urls.some((url) => url.includes("[toolkitId]"))).toBe(false); + expect(urls).toContain( + "https://docs.arcade.dev/en/resources/integrations/productivity/gmail" + ); + }); + + test("groups generated toolkit pages by integration category", () => { + const sections = organizeSections([ + { + path: "toolkit-docs-generator/data/toolkits/gmail.json", + url: "https://docs.arcade.dev/en/resources/integrations/productivity/gmail", + content: "# Gmail", + title: "Gmail", + description: "Gmail tools", + }, + ]); + + expect(sections).toEqual([ + { + name: "Integrations - Productivity", + pages: [ + { + title: "Gmail", + url: "https://docs.arcade.dev/en/resources/integrations/productivity/gmail", + description: "Gmail tools", + }, + ], + }, + ]); + }); + + test("ignores metadata-only changes that would cause commit loops", () => { + const first = + "\n\n# Arcade\n"; + const second = + "\n\n# Arcade\n"; + + expect(hasLlmsBodyChanged(first, second)).toBe(false); + expect(hasLlmsBodyChanged(first, `${second}\nNew page`)).toBe(true); + }); + + test("workflow fetches history and skips write-capability checks on forks", () => { + const workflow = readFileSync( + join(process.cwd(), ".github/workflows/llmstxt.yml"), + "utf-8" + ); + + expect(workflow).toContain("fetch-depth: 0"); + expect(workflow).toContain( + "github.event.pull_request.head.repo.full_name == github.repository" + ); + }); +}); diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts index 854fa6234..6cd930117 100644 --- a/tests/sitemap.test.ts +++ b/tests/sitemap.test.ts @@ -22,6 +22,18 @@ test("sitemap lists expected URLs", async () => { // Known page should be present expect(urls).toContain("https://example.test/en/references/changelog"); + const { listToolkitRoutes } = await import( + "../app/_lib/toolkit-static-params" + ); + const toolkitUrls = (await listToolkitRoutes()).map( + ({ category, toolkitId }) => + `https://example.test/en/resources/integrations/${category}/${toolkitId}` + ); + expect(toolkitUrls.length).toBeGreaterThan(0); + for (const toolkitUrl of toolkitUrls) { + expect(urls).toContain(toolkitUrl); + } + // No duplicates const duplicates = urls.filter( (url, index, arr) => arr.indexOf(url) !== index diff --git a/tests/toolkit-markdown.test.ts b/tests/toolkit-markdown.test.ts index 456358329..df5fb15e9 100644 --- a/tests/toolkit-markdown.test.ts +++ b/tests/toolkit-markdown.test.ts @@ -22,6 +22,15 @@ const fixture: ToolkitData = { docsLink: "", }, auth: null, + documentationChunks: [ + { + type: "markdown", + location: "description", + position: "after", + content: "## Toolkit setup\n\nConfigure the toolkit first.", + priority: 10, + }, + ], customImports: [], subPages: [], tools: [ @@ -43,7 +52,15 @@ const fixture: ToolkitData = { secrets: ["API_KEY"], secretsInfo: [], output: { type: "json", description: "The result" }, - documentationChunks: [], + documentationChunks: [ + { + type: "warning", + location: "parameters", + position: "before", + content: "Use a verified recipient.", + priority: 20, + }, + ], codeExample: { toolName: "Demo.DoThing", parameters: { @@ -72,4 +89,115 @@ describe("toToolkitMarkdown", () => { expect(md).toContain("API_KEY"); expect(md).toContain("Example input"); }); + + test("includes toolkit and tool documentation chunks", () => { + expect(md).toContain("## Toolkit setup"); + expect(md).toContain("Configure the toolkit first."); + expect(md).toContain("Use a verified recipient."); + expect(md.indexOf("## Toolkit setup")).toBeLessThan(md.indexOf("## Tools")); + expect(md.indexOf("Use a verified recipient.")).toBeLessThan( + md.indexOf("**Parameters**") + ); + }); + + test("preserves repeated chunk content at different locations", () => { + const repeated = "Repeat this guidance."; + const output = toToolkitMarkdown({ + ...fixture, + documentationChunks: [ + { + type: "info", + location: "description", + position: "after", + content: repeated, + }, + { + type: "warning", + location: "auth", + position: "before", + content: repeated, + }, + ], + }); + + expect(output.split(repeated)).toHaveLength(3); + }); + + test("uses replacement chunks instead of the default section", () => { + const output = toToolkitMarkdown({ + ...fixture, + tools: [ + { + ...fixture.tools[0], + documentationChunks: [ + { + type: "markdown", + location: "parameters", + position: "replace", + content: "Custom parameter guidance.", + }, + ], + }, + ], + }); + + expect(output).toContain("Custom parameter guidance."); + expect(output).not.toContain("| Name | Type | Required | Description |"); + }); + + test("empty replacement chunks still suppress the default section", () => { + const output = toToolkitMarkdown({ + ...fixture, + tools: [ + { + ...fixture.tools[0], + documentationChunks: [ + { + type: "markdown", + location: "parameters", + position: "replace", + content: " ", + }, + ], + }, + ], + }); + + expect(output).not.toContain("| Name | Type | Required | Description |"); + }); + + test("sorts headed chunks before unheaded chunks like the page renderer", () => { + const output = toToolkitMarkdown({ + ...fixture, + documentationChunks: [ + { + type: "markdown", + location: "custom_section", + position: "before", + content: "No header", + }, + { + type: "markdown", + location: "custom_section", + position: "before", + content: "Section B", + header: "## B", + }, + { + type: "markdown", + location: "custom_section", + position: "before", + content: "Section A", + header: "## A", + }, + ], + }); + + expect(output.indexOf("Section A")).toBeLessThan( + output.indexOf("Section B") + ); + expect(output.indexOf("Section B")).toBeLessThan( + output.indexOf("No header") + ); + }); }); diff --git a/toolkit-docs-generator/ARCHITECTURE.md b/toolkit-docs-generator/ARCHITECTURE.md index 14efe20db..67022ac8c 100644 --- a/toolkit-docs-generator/ARCHITECTURE.md +++ b/toolkit-docs-generator/ARCHITECTURE.md @@ -58,16 +58,33 @@ It can also verify output consistency with `OutputVerifier`. The generator output is consumed by the Next.js app: - The app loads JSON from `toolkit-docs-generator/data/toolkits/`. -- Toolkit pages are statically generated using those files. +- `generateStaticParams` enumerates the toolkit routes and disables unknown dynamic parameters. - Custom documentation chunks are rendered as MDX in the UI. If you need HTML output, add a separate build step in the app. The generator intentionally avoids HTML to keep the pipeline deterministic. -## Static page generation +## Vercel build -Static generation happens in the app, not in this generator. +The generator does not run during a Vercel build. The generation workflow commits +the JSON files and navigation changes through a pull request. Vercel then runs the +root `pnpm build` command and compiles the committed files with Next.js. -The generator only provides JSON and markdown content. The app turns those into static HTML during its build. +`app/_lib/toolkit-static-params.ts` enumerates routes from `index.json` and the +per-toolkit files. `app/_lib/toolkit-data.ts` reads the same files for page +rendering and the `/api/toolkit-data/[toolkitId]` route. The root layout reads +request headers, so Vercel reports the docs routes as dynamic even though the +toolkit parameter set is fixed at build time. + +## Search indexing + +Search uses an external Algolia crawler. There is no Pagefind or local search +index build step in this repository. After deployment, the crawler indexes the +rendered site. `app/_components/algolia-search.tsx` queries that index with the +public, read-only values configured through these Vercel environment variables: + +- `NEXT_PUBLIC_ALGOLIA_APP_ID` +- `NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY` +- `NEXT_PUBLIC_ALGOLIA_INDEX_NAME` ## Key files diff --git a/toolkit-docs-generator/README.md b/toolkit-docs-generator/README.md index 7b3a65c0e..08289c621 100644 --- a/toolkit-docs-generator/README.md +++ b/toolkit-docs-generator/README.md @@ -19,30 +19,29 @@ The generator merges three inputs into one JSON output per toolkit: It also reads the previous output when you use `--skip-unchanged` or `--previous-output`. -When `--skip-unchanged` runs against the tool metadata API, the generator calls -the summary endpoint (`/v1/tool_metadata_summary`) first to decide which toolkits -need updates. It only fetches full tool metadata from `/v1/tool_metadata` for -toolkits with version changes. The Engine API guarantees that tool definitions -do not change unless the toolkit version changes. +When `--skip-unchanged` runs against the tool metadata API, the generator fetches +one complete snapshot from `/v1/tool_metadata`. It reuses that snapshot for +change detection, progress calculation, and generation so a run cannot compare +different API states. Only changed toolkits are regenerated. ## GitHub workflow: generate and sync The workflow file is `/.github/workflows/generate-toolkit-docs.yml`. It runs these steps: -1. Generate toolkit JSON using `toolkit-docs-generator` and the Engine API. -2. Sync sidebar navigation from `toolkit-docs-generator/data/toolkits` to the `_meta.tsx` files. -3. Create a pull request if there are changes. +1. Type-check and test the toolkit docs generator. +2. Generate toolkit JSON using `toolkit-docs-generator` and the Engine API. +3. Sync sidebar navigation from `toolkit-docs-generator/data/toolkits` to the `_meta.tsx` files. +4. Create or update a pull request if there are changes. Required secrets: - `ENGINE_API_URL`, `ENGINE_API_KEY` -- `OPENAI_API_KEY` for LLM examples and summaries +- `ANTHROPIC_API_KEY` for examples, summaries, and secret-coherence edits Optional secrets: -- `OPENAI_MODEL` (defaults in the workflow) -- `ANTHROPIC_API_KEY` enables the secret-coherence editor (see below). Without it the workflow still runs; the scanners emit warnings but no LLM edits are applied. +- `ANTHROPIC_MODEL` (defaults to `claude-sonnet-4-6` in the workflow) - `ANTHROPIC_EDITOR_MODEL` (defaults to `claude-sonnet-4-6` in the workflow) ## Rendering pipeline (docs site) diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 01927bec6..28634a54c 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -28,6 +28,7 @@ import { import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { TOOLKITS as DESIGN_SYSTEM_TOOLKITS } from "@arcadeai/design-system/metadata/toolkits"; +import { getToolkitSlug } from "../../app/_lib/toolkit-slug"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -83,21 +84,13 @@ const CATEGORY_ORDER = [ "customer-support", "others", ]; +const CATEGORY_SET = new Set(CATEGORY_ORDER); const CAPITAL_LETTER_REGEX = /([A-Z])/g; const FIRST_CHARACTER_REGEX = /^./; -const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; const IDENTIFIER_KEY_REGEX = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -/** - * Convert a CamelCase string to kebab-case. - * Must stay in sync with toKebabCase in app/_lib/toolkit-slug.ts. - */ -function toKebabCase(value: string): string { - return value.replace(CAMEL_BOUNDARY, "$1-$2").toLowerCase(); -} - type ToolkitJson = { id?: string; label?: string; @@ -114,10 +107,12 @@ function renderObjectKey(key: string): string { if (IDENTIFIER_KEY_REGEX.test(key)) { return key; } - const escaped = key.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; + return JSON.stringify(key); } +const normalizeCategory = (category: string | null | undefined): string => + category && CATEGORY_SET.has(category) ? category : "others"; + export type ToolkitInfo = { id: string; slug: string; @@ -231,21 +226,6 @@ function readToolkitJson(dataDir: string, slug: string): ToolkitJson | null { return null; } -function getDocsSlugFromLink(docsLink?: string | null): string | null { - if (!docsLink) { - return null; - } - - try { - const url = new URL(docsLink); - const segments = url.pathname.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } catch { - const segments = docsLink.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } -} - /** * Read toolkit JSON and extract label if available */ @@ -294,8 +274,13 @@ function resolveToolkitInfo( ): ToolkitInfoEntry | null { const jsonData = readToolkitJson(dataDir, slug); const toolkitId = jsonData?.id ?? slug; - const docsSlug = - getDocsSlugFromLink(jsonData?.metadata?.docsLink) ?? toKebabCase(toolkitId); + const docsSlug = getToolkitSlug({ + id: toolkitId, + docsLink: jsonData?.metadata?.docsLink, + }); + if (!docsSlug) { + return null; + } const designSystemToolkit = TOOLKITS.find( (t) => t.id.toLowerCase() === toolkitId.toLowerCase() ); @@ -306,8 +291,9 @@ function resolveToolkitInfo( // Keep sidebar routes aligned with static params: toolkit JSON is source of // truth for category, with design system as fallback when JSON is missing. - const category = - jsonData?.metadata?.category ?? designSystemToolkit?.category ?? "others"; + const category = normalizeCategory( + jsonData?.metadata?.category ?? designSystemToolkit?.category + ); const labelFromDesignSystem = designSystemToolkit?.label ?? null; const labelFromJson = jsonData?.label ?? jsonData?.name ?? null; const typeFromJson = jsonData?.metadata?.type ?? null; @@ -382,6 +368,7 @@ export function generateCategoryMeta( category: string, integrationsBasePath: string ): string { + const safeCategory = normalizeCategory(category); const byLabel = (a: ToolkitInfo, b: ToolkitInfo) => a.label.localeCompare(b.label); @@ -393,11 +380,10 @@ export function generateCategoryMeta( .sort(byLabel); const renderEntry = (t: ToolkitInfo) => { - // Escape any quotes in the label - const escapedLabel = t.label.replace(/"/g, '\\"'); + const href = `${integrationsBasePath}/${safeCategory}/${t.slug}`; return ` ${renderObjectKey(t.slug)}: { - title: "${escapedLabel}", - href: "${integrationsBasePath}/${category}/${t.slug}", + title: ${JSON.stringify(t.label)}, + href: ${JSON.stringify(href)}, }`; }; @@ -405,7 +391,7 @@ export function generateCategoryMeta( const key = `-- ${title}`; return ` ${renderObjectKey(key)}: { type: "separator", - title: "${title}", + title: ${JSON.stringify(title)}, }`; }; @@ -460,7 +446,7 @@ export function generateMainMeta(activeCategories: string[]): string { .map((cat) => { const displayName = CATEGORY_NAMES[cat] || cat; return ` ${renderObjectKey(cat)}: { - title: "${displayName}", + title: ${JSON.stringify(displayName)}, }`; }) .join(",\n"); diff --git a/toolkit-docs-generator/src/cli/generate-flow.ts b/toolkit-docs-generator/src/cli/generate-flow.ts index 96f2885f0..194634e19 100644 --- a/toolkit-docs-generator/src/cli/generate-flow.ts +++ b/toolkit-docs-generator/src/cli/generate-flow.ts @@ -15,6 +15,21 @@ export const collectRemovedToolkitIds = ( .map((c) => c.toolkitId.toLowerCase()) ); +/** + * Protect incremental runs from treating a transient empty API response as a + * request to delete every previously generated toolkit. + */ +export const assertSafeCurrentToolkitSnapshot = ( + currentToolkitCount: number, + previousToolkitCount: number +): void => { + if (currentToolkitCount === 0 && previousToolkitCount > 0) { + throw new Error( + `Current toolkit snapshot is empty; refusing to remove all ${previousToolkitCount} existing toolkits.` + ); + } +}; + export interface ProcessingStats { totalToolkits: number; effectiveSkipped: number; diff --git a/toolkit-docs-generator/src/cli/index.ts b/toolkit-docs-generator/src/cli/index.ts index bb05939f6..bf30fdd63 100644 --- a/toolkit-docs-generator/src/cli/index.ts +++ b/toolkit-docs-generator/src/cli/index.ts @@ -13,7 +13,7 @@ import chalk from "chalk"; import { Command } from "commander"; -import { readdir, readFile, rm } from "fs/promises"; +import { readdir, readFile } from "fs/promises"; import ora from "ora"; import { join, resolve } from "path"; import { @@ -46,13 +46,23 @@ import { createMockMetadataSource } from "../sources/mock-metadata.js"; import { createDesignSystemProviderIdResolver } from "../sources/oauth-provider-resolver.js"; import { createArcadeToolkitDataSource, + createCachedToolkitDataSource, createEngineToolkitDataSource, createMockToolkitDataSource, type IToolkitDataSource, type ToolkitData, } from "../sources/toolkit-data-source.js"; +import { + type MergedToolkit, + type ProviderVersion, + ProviderVersionSchema, +} from "../types/index.js"; import { readExclusionList } from "../utils/exclusion-list.js"; import { readIgnoreList } from "../utils/ignore-list.js"; +import { + clearSafeOutputDir, + resolveDefaultOutputDir, +} from "../utils/output-dir.js"; import { createProgressTracker, formatToolkitComplete, @@ -63,27 +73,15 @@ import { readFailedToolsReport, writeFailedToolsReport, } from "../utils/run-logs.js"; +import { type ApiSource, resolveApiSource } from "./api-source.js"; import { cleanupExcludedToolkitOutput } from "./exclusion-cleanup.js"; import { + assertSafeCurrentToolkitSnapshot, collectRemovedToolkitIds, computeProcessingStats, filterProvidersBySkipIds, } from "./generate-flow.js"; -/** - * Supported API sources: - * - "list-tools": Uses /v1/tools endpoint (production Arcade API) - * - "tool-metadata": Uses /v1/tool_metadata endpoint (Engine API) - * - "mock": Uses local mock data files - */ -type ApiSource = "list-tools" | "tool-metadata" | "mock"; - -import { - type MergedToolkit, - type ProviderVersion, - ProviderVersionSchema, -} from "../types/index.js"; - const program = new Command(); /** @@ -118,17 +116,6 @@ const parseProviders = (input: string): ProviderVersion[] => { */ const getDefaultMockDataDir = (): string => join(process.cwd(), "mock-data"); -/** - * Get the default output directory for docs JSON. - */ -const getDefaultOutputDir = (): string => { - const cwd = process.cwd(); - if (cwd.endsWith("toolkit-docs-generator")) { - return resolve(cwd, "..", "data", "toolkits"); - } - return resolve(cwd, "data", "toolkits"); -}; - const getDefaultVerificationDir = (): string => { const cwd = process.cwd(); if (cwd.endsWith("toolkit-docs-generator")) { @@ -265,12 +252,7 @@ const clearOutputDir = async ( outputDir: string, verbose: boolean ): Promise => { - const resolvedDir = resolve(outputDir); - const repoRoot = resolve(process.cwd()); - if (resolvedDir === "/" || resolvedDir === repoRoot) { - throw new Error(`Refusing to overwrite output directory: ${resolvedDir}`); - } - await rm(resolvedDir, { recursive: true, force: true }); + const resolvedDir = await clearSafeOutputDir(outputDir); if (verbose) { console.log(chalk.dim(`Cleared output directory: ${resolvedDir}`)); } @@ -485,51 +467,6 @@ const resolveSecretEditGenerator = ( }); }; -const resolveApiSource = (options: { - apiSource?: string; - toolMetadataUrl?: string; - toolMetadataKey?: string; - listToolsUrl?: string; - listToolsKey?: string; -}): ApiSource => { - // Explicit source takes precedence - if (options.apiSource) { - const source = options.apiSource.toLowerCase(); - // Support both old and new names for backwards compatibility - if (source === "list-tools" || source === "arcade") { - return "list-tools"; - } - if (source === "tool-metadata" || source === "engine") { - return "tool-metadata"; - } - if (source === "mock") { - return "mock"; - } - throw new Error( - `Invalid --api-source "${options.apiSource}". Use "list-tools", "tool-metadata", or "mock".` - ); - } - - // Auto-detect based on provided credentials - const hasListToolsKey = !!( - options.listToolsKey ?? process.env.ARCADE_API_KEY - ); - const hasToolMetadataKey = !!( - options.toolMetadataKey ?? process.env.ENGINE_API_KEY - ); - const hasToolMetadataUrl = !!( - options.toolMetadataUrl ?? process.env.ENGINE_API_URL - ); - - if (hasListToolsKey) { - return "list-tools"; - } - if (hasToolMetadataKey && hasToolMetadataUrl) { - return "tool-metadata"; - } - return "mock"; -}; - interface ToolkitDataSourceOptions { apiSource?: string; listToolsUrl?: string; @@ -855,6 +792,10 @@ const processProviders = async ( } } catch (error) { spinner.fail(`${pv.provider}: ${error}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to process ${pv.provider}: ${message}`, { + cause: error, + }); } } @@ -878,7 +819,7 @@ program "Path to failed tools report to rerun only impacted toolkits" ) .option("--all", "Generate documentation for all toolkits", false) - .option("-o, --output ", "Output directory", getDefaultOutputDir()) + .option("-o, --output ", "Output directory", resolveDefaultOutputDir()) .option("--log-dir ", "Directory for run logs", getDefaultLogDir()) .option("--mock-data-dir ", "Path to mock data directory") .option("--metadata-file ", "Path to metadata JSON file") @@ -1209,13 +1150,15 @@ program // Create toolkit data source based on API source const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - options.verbose, - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + options.verbose, + spinner + ) ); const needsExamples = !options.skipExamples; @@ -1386,6 +1329,10 @@ program } currentToolkitDataForDiff.set(id, data); } + assertSafeCurrentToolkitSnapshot( + currentToolkitDataForDiff.size, + previousToolkits?.size ?? 0 + ); // Detect changes const compareStartedAt = Date.now(); @@ -1714,11 +1661,14 @@ program } } + const mergeFailures = allResults.filter((result) => result.error); + const writableResults = allResults.filter((result) => !result.error); + // Generate output files (batch mode if not incremental) - if (!useIncremental && allResults.length > 0) { + if (!useIncremental && writableResults.length > 0) { spinner.start("Writing output files..."); - const toolkits = allResults.map((r) => r.toolkit); + const toolkits = writableResults.map((result) => result.toolkit); const genResult = await generator.generateAll(toolkits); filesWritten.push(...genResult.filesWritten); @@ -1731,13 +1681,15 @@ program } } else if (useIncremental) { // Generate index file for incremental mode - if (allResults.length > 0 || skipToolkitIds.size > 0) { + if (writableResults.length > 0 || skipToolkitIds.size > 0) { spinner.start("Generating index file..."); try { const existingToolkits = await generator.getCompletedToolkitIds(); const allToolkitIds = new Set([ ...existingToolkits, - ...allResults.map((r) => r.toolkit.id.toLowerCase()), + ...writableResults.map((result) => + result.toolkit.id.toLowerCase() + ), ]); // Load all toolkits for index @@ -1797,7 +1749,11 @@ program } // Print summary - if (filesWritten.length > 0 || writeErrors.length > 0) { + if ( + filesWritten.length > 0 || + writeErrors.length > 0 || + mergeFailures.length > 0 + ) { console.log(chalk.green("\n✓ Generation complete\n")); if (options.verbose) { console.log(chalk.dim("Files:")); @@ -1814,6 +1770,14 @@ program console.log(chalk.yellow(` ${error}`)); } } + if (mergeFailures.length > 0) { + console.log(chalk.red("\nFailed toolkits:")); + for (const failure of mergeFailures) { + console.log( + chalk.red(` ${failure.toolkit.id}: ${failure.error}`) + ); + } + } } if (options.verifyOutput) { @@ -1846,13 +1810,7 @@ program (count, result) => count + result.warnings.length, 0 ); - const failedToolkits = allResults - .filter((result) => - result.warnings.some((warning) => - warning.startsWith("Error processing toolkit") - ) - ) - .map((result) => result.toolkit.id); + const failedToolkits = mergeFailures.map((result) => result.toolkit.id); const failedTools = allResults.flatMap((result) => result.failedTools); const failedToolkitsFromTools = Array.from( new Set(failedTools.map((tool) => tool.toolkitId)) @@ -1905,6 +1863,9 @@ program title: "generate", details: runDetails, }); + if (mergeFailures.length > 0 || writeErrors.length > 0) { + process.exitCode = 1; + } } catch (error) { spinner.fail( `Error: ${error instanceof Error ? error.message : String(error)}` @@ -1924,7 +1885,7 @@ program program .command("generate-all") .description("Generate documentation for all toolkits in mock data") - .option("-o, --output ", "Output directory", getDefaultOutputDir()) + .option("-o, --output ", "Output directory", resolveDefaultOutputDir()) .option("--mock-data-dir ", "Path to mock data directory") .option("--metadata-file ", "Path to metadata JSON file") .option( @@ -2144,13 +2105,15 @@ program // Create toolkit data source based on API source const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - options.verbose, - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + options.verbose, + spinner + ) ); const needsExamples = !options.skipExamples; @@ -2415,6 +2378,8 @@ program spinner.start(progressTracker.getProgressString()); const results = await merger.mergeAllToolkits(); + const mergeFailures = results.filter((result) => result.error); + const writableResults = results.filter((result) => !result.error); const summary = progressTracker.getSummary(); spinner.succeed( `Processed ${summary.completed} toolkit(s) with ${summary.totalTools} tools in ${summary.elapsed}` @@ -2435,11 +2400,20 @@ program console.log(chalk.dim(` - ${warning}`)); } } + if (mergeFailures.length > 0) { + console.log(chalk.red("\nFailed toolkits:")); + for (const failure of mergeFailures) { + console.log( + chalk.red(` ${failure.toolkit.id}: ${failure.error}`) + ); + } + process.exitCode = 1; + } // Generate output (batch mode if not incremental) - if (!useIncremental && results.length > 0) { + if (!useIncremental && writableResults.length > 0) { spinner.start("Writing output files..."); - const toolkits = results.map((r) => r.toolkit); + const toolkits = writableResults.map((result) => result.toolkit); const genResult = await generator.generateAll(toolkits); filesWritten.push(...genResult.filesWritten); @@ -2461,7 +2435,9 @@ program const existingToolkits = await generator.getCompletedToolkitIds(); const allToolkitIds = new Set([ ...existingToolkits, - ...results.map((r) => r.toolkit.id.toLowerCase()), + ...writableResults.map((result) => + result.toolkit.id.toLowerCase() + ), ]); const toolkitsForIndex: MergedToolkit[] = []; @@ -2553,6 +2529,7 @@ program for (const warning of writeErrors) { console.log(chalk.yellow(` ${warning}`)); } + process.exitCode = 1; } } catch (error) { spinner.fail( @@ -2627,7 +2604,7 @@ program program .command("verify-output") .description("Verify output directory structure and schema") - .option("-o, --output ", "Output directory", getDefaultOutputDir()) + .option("-o, --output ", "Output directory", resolveDefaultOutputDir()) .option("--verbose", "Show detailed progress", false) .action(async (options: { output: string; verbose: boolean }) => { const spinner = ora("Verifying output...").start(); @@ -2667,7 +2644,7 @@ program .option( "-o, --output ", "Previous output directory", - getDefaultOutputDir() + resolveDefaultOutputDir() ) .option("--log-dir ", "Directory for run logs", getDefaultLogDir()) .option("--mock-data-dir ", "Path to mock data directory") @@ -2727,13 +2704,15 @@ program }); const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - false, // not verbose during fetch - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + false, // not verbose during fetch + spinner + ) ); // Fetch current data from API diff --git a/toolkit-docs-generator/src/generator/json-generator.ts b/toolkit-docs-generator/src/generator/json-generator.ts index 2991d3b6a..e032ab76d 100644 --- a/toolkit-docs-generator/src/generator/json-generator.ts +++ b/toolkit-docs-generator/src/generator/json-generator.ts @@ -3,7 +3,8 @@ * * Outputs merged toolkit data as JSON files. */ -import { mkdir, readFile, stat, writeFile } from "fs/promises"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises"; import { dirname, join } from "path"; import { parsePreviousToolkitForDiff } from "../diff/previous-output.js"; import type { @@ -17,6 +18,60 @@ import { type ToolkitReadResult, } from "./output-verifier.js"; +const SAFE_TOOLKIT_ID = /^[a-z0-9][a-z0-9_-]*$/i; +const RESERVED_TOOLKIT_ID = "index"; + +const getToolkitFileName = (toolkitId: string): string => { + const normalizedId = toolkitId.toLowerCase(); + if (normalizedId === RESERVED_TOOLKIT_ID) { + throw new Error(`"${toolkitId}" is a reserved toolkit ID`); + } + if (!SAFE_TOOLKIT_ID.test(toolkitId)) { + throw new Error(`Unsafe toolkit ID: "${toolkitId}"`); + } + return `${normalizedId}.json`; +}; + +const validateToolkitFileNames = ( + toolkits: readonly MergedToolkit[] +): string[] => { + const errors: string[] = []; + const toolkitIdsByFile = new Map(); + + for (const toolkit of toolkits) { + try { + const fileName = getToolkitFileName(toolkit.id); + const toolkitIds = toolkitIdsByFile.get(fileName) ?? []; + toolkitIdsByFile.set(fileName, [...toolkitIds, toolkit.id]); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + for (const [fileName, toolkitIds] of toolkitIdsByFile) { + if (toolkitIds.length > 1) { + errors.push( + `Toolkit IDs collide on ${fileName}: ${toolkitIds.join(", ")}` + ); + } + } + + return errors; +}; + +const writeFileAtomically = async ( + filePath: string, + content: string +): Promise => { + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tempPath, content, "utf-8"); + await rename(tempPath, filePath); + } finally { + await rm(tempPath, { force: true }); + } +}; + // ============================================================================ // Generator Configuration // ============================================================================ @@ -86,7 +141,7 @@ export class JsonGenerator { } } - const fileName = `${toolkit.id.toLowerCase()}.json`; + const fileName = getToolkitFileName(toolkit.id); const filePath = join(this.outputDir, fileName); // Ensure directory exists @@ -97,7 +152,7 @@ export class JsonGenerator { ? JSON.stringify(toolkit, null, 2) : JSON.stringify(toolkit); - await writeFile(filePath, content, "utf-8"); + await writeFileAtomically(filePath, content); return filePath; } @@ -105,9 +160,9 @@ export class JsonGenerator { * Check if a toolkit file already exists in the output directory */ async hasToolkitFile(toolkitId: string): Promise { - const fileName = `${toolkitId.toLowerCase()}.json`; - const filePath = join(this.outputDir, fileName); try { + const fileName = getToolkitFileName(toolkitId); + const filePath = join(this.outputDir, fileName); const stats = await stat(filePath); return stats.isFile(); } catch { @@ -137,9 +192,9 @@ export class JsonGenerator { * Load an existing toolkit file */ async loadToolkitFile(toolkitId: string): Promise { - const fileName = `${toolkitId.toLowerCase()}.json`; - const filePath = join(this.outputDir, fileName); try { + const fileName = getToolkitFileName(toolkitId); + const filePath = join(this.outputDir, fileName); const content = await readFile(filePath, "utf-8"); const parsed = JSON.parse(content) as unknown; const result = MergedToolkitSchema.safeParse(parsed); @@ -160,7 +215,10 @@ export class JsonGenerator { toolkits: readonly MergedToolkit[] ): Promise { const filesWritten: string[] = []; - const errors: string[] = []; + const errors = validateToolkitFileNames(toolkits); + if (errors.length > 0) { + return { filesWritten, errors }; + } // Generate per-toolkit files for (const toolkit of toolkits) { @@ -199,15 +257,21 @@ export class JsonGenerator { private async generateIndexFile( toolkits: readonly MergedToolkit[] ): Promise { - const entries: ToolkitIndexEntry[] = toolkits.map((t) => ({ - id: t.id, - label: t.label, - version: t.version, - category: t.metadata.category, - type: t.metadata.type, - toolCount: t.tools.length, - authType: t.auth?.type ?? "none", - })); + const entries: ToolkitIndexEntry[] = toolkits + .map((t) => ({ + id: t.id, + label: t.label, + version: t.version, + category: t.metadata.category, + type: t.metadata.type, + toolCount: t.tools.length, + authType: t.auth?.type ?? "none", + })) + .sort( + (left, right) => + left.id.toLowerCase().localeCompare(right.id.toLowerCase()) || + left.id.localeCompare(right.id) + ); const index: ToolkitIndex = { generatedAt: new Date().toISOString(), @@ -223,7 +287,7 @@ export class JsonGenerator { ? JSON.stringify(index, null, 2) : JSON.stringify(index); - await writeFile(filePath, content, "utf-8"); + await writeFileAtomically(filePath, content); return filePath; } diff --git a/toolkit-docs-generator/src/merger/data-merger.ts b/toolkit-docs-generator/src/merger/data-merger.ts index 6696dfd89..b73f9139c 100644 --- a/toolkit-docs-generator/src/merger/data-merger.ts +++ b/toolkit-docs-generator/src/merger/data-merger.ts @@ -93,6 +93,7 @@ export interface MergeResult { toolkit: MergedToolkit; warnings: string[]; failedTools: FailedTool[]; + error?: string; } export interface ToolExampleResult { @@ -987,6 +988,15 @@ export class DataMerger { message: string, previousToolkit?: MergedToolkit ): MergeResult { + if (previousToolkit) { + return { + toolkit: previousToolkit, + warnings: [`Error processing toolkit: ${message}`], + failedTools: [], + error: message, + }; + } + return { toolkit: { id: toolkitId, @@ -1005,13 +1015,14 @@ export class DataMerger { }, auth: null, tools: [], - documentationChunks: previousToolkit?.documentationChunks ?? [], - customImports: previousToolkit?.customImports ?? [], - subPages: previousToolkit?.subPages ?? [], + documentationChunks: [], + customImports: [], + subPages: [], generatedAt: new Date().toISOString(), }, warnings: [`Error processing toolkit: ${message}`], failedTools: [], + error: message, }; } @@ -1049,6 +1060,11 @@ export class DataMerger { return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (this.requireCompleteData) { + throw new Error(`Failed to process ${toolkitId}: ${message}`, { + cause: error, + }); + } const previousToolkit = this.getPreviousToolkit(toolkitId); return this.buildMergeErrorResult(toolkitId, message, previousToolkit); } diff --git a/toolkit-docs-generator/src/sources/custom-sections-file.ts b/toolkit-docs-generator/src/sources/custom-sections-file.ts index 636b195ad..2ed843062 100644 --- a/toolkit-docs-generator/src/sources/custom-sections-file.ts +++ b/toolkit-docs-generator/src/sources/custom-sections-file.ts @@ -7,7 +7,10 @@ import { access, readFile } from "fs/promises"; import { z } from "zod"; import type { CustomSections } from "../types/index.js"; -import { DocumentationChunkSchema } from "../types/index.js"; +import { + DocumentationChunkSchema, + ToolkitSubPageSchema, +} from "../types/index.js"; import { normalizeId } from "../utils/fp.js"; import type { ICustomSectionsSource } from "./interfaces.js"; @@ -20,7 +23,7 @@ const CustomSectionsFileSchema = z.record( z.object({ documentationChunks: z.array(DocumentationChunkSchema).default([]), customImports: z.array(z.string()).default([]), - subPages: z.array(z.string()).default([]), + subPages: z.array(ToolkitSubPageSchema).default([]), toolChunks: z .record(z.string(), z.array(DocumentationChunkSchema)) .default({}), diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 3ee4e1fcf..1b6b191a6 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -75,6 +75,30 @@ export interface IToolkitDataSource { readonly isAvailable: () => Promise; } +/** + * Reuse one all-toolkit snapshot for the lifetime of a generation run. + * + * Change detection, progress setup, and merging all consume the same data + * instead of issuing independent API reads that can disagree mid-run. + */ +export const createCachedToolkitDataSource = ( + source: IToolkitDataSource +): IToolkitDataSource => { + let allToolkitsSnapshot: + | Promise> + | undefined; + + return { + fetchToolkitData: (toolkitId, version) => + source.fetchToolkitData(toolkitId, version), + fetchAllToolkitsData: () => { + allToolkitsSnapshot ??= source.fetchAllToolkitsData(); + return allToolkitsSnapshot; + }, + isAvailable: () => source.isAvailable(), + }; +}; + // ============================================================================ // Combined Implementation (Current: Separate Sources) // ============================================================================ diff --git a/toolkit-docs-generator/src/types/index.ts b/toolkit-docs-generator/src/types/index.ts index 366dde7aa..c8dba520c 100644 --- a/toolkit-docs-generator/src/types/index.ts +++ b/toolkit-docs-generator/src/types/index.ts @@ -316,13 +316,22 @@ export type ToolCodeExample = z.infer; // Custom Sections Schema (extracted from MDX) // ============================================================================ +export const ToolkitSubPageSchema = z.union([ + z.string(), + z.object({ + type: z.string().min(1), + content: z.string(), + relativePath: z.string().min(1), + }), +]); + export const CustomSectionsSchema = z.object({ /** Toolkit-level documentation chunks */ documentationChunks: z.array(DocumentationChunkSchema).default([]), /** Custom imports needed for the MDX file */ customImports: z.array(z.string()).default([]), /** Sub-pages that exist for this toolkit */ - subPages: z.array(z.string()).default([]), + subPages: z.array(ToolkitSubPageSchema).default([]), /** Per-tool documentation chunks (keyed by tool name) */ toolChunks: z .record(z.string(), z.array(DocumentationChunkSchema)) @@ -428,9 +437,7 @@ export const MergedToolkitSchema = z.object({ * Each entry is either a string (legacy slug) or a rich object with * { type, content, relativePath } for inline MDX sub-page content. */ - subPages: z - .array(z.union([z.string(), z.record(z.string(), z.unknown())])) - .default([]), + subPages: z.array(ToolkitSubPageSchema).default([]), /** Generation metadata */ generatedAt: z.string().optional(), }); diff --git a/toolkit-docs-generator/src/utils/output-dir.ts b/toolkit-docs-generator/src/utils/output-dir.ts index 22b925a36..50a244e63 100644 --- a/toolkit-docs-generator/src/utils/output-dir.ts +++ b/toolkit-docs-generator/src/utils/output-dir.ts @@ -1,11 +1,27 @@ -import { realpath } from "fs/promises"; -import { isAbsolute, resolve, sep } from "path"; +import { realpath, rm } from "fs/promises"; +import { basename, isAbsolute, resolve, sep } from "path"; type ResolveOptions = { repoRoot?: string; homeDir?: string; }; +export const resolveRepositoryRoot = (cwd: string = process.cwd()): string => + basename(cwd) === "toolkit-docs-generator" + ? resolve(cwd, "..") + : resolve(cwd); + +export const resolveDefaultOutputDir = ( + cwd: string = process.cwd() +): string => { + const repoRoot = resolveRepositoryRoot(cwd); + const generatorRoot = + basename(cwd) === "toolkit-docs-generator" + ? cwd + : resolve(repoRoot, "toolkit-docs-generator"); + return resolve(generatorRoot, "data", "toolkits"); +}; + const isSubpath = (parent: string, child: string): boolean => { const normalizedParent = parent.endsWith(sep) ? parent : `${parent}${sep}`; return child === parent || child.startsWith(normalizedParent); @@ -15,7 +31,9 @@ export const resolveSafeOutputDir = async ( outputDir: string, options: ResolveOptions = {} ): Promise => { - const resolvedRepoRoot = resolve(options.repoRoot ?? process.cwd()); + const resolvedRepoRoot = resolve( + options.repoRoot ?? resolveRepositoryRoot(process.cwd()) + ); const repoRoot = await realpath(resolvedRepoRoot).catch( () => resolvedRepoRoot ); @@ -30,12 +48,9 @@ export const resolveSafeOutputDir = async ( const resolvedDir = resolve(outputDir); const realDir = await realpath(resolvedDir).catch(() => resolvedDir); - const forbidden = new Set(["/", repoRoot]); - if (homeDir) { - forbidden.add(homeDir); - } - - if (forbidden.has(realDir)) { + const containsRepoRoot = isSubpath(realDir, repoRoot); + const containsHomeDir = homeDir ? isSubpath(realDir, homeDir) : false; + if (containsRepoRoot || containsHomeDir) { throw new Error(`Refusing to delete unsafe output directory: ${realDir}`); } @@ -48,3 +63,12 @@ export const resolveSafeOutputDir = async ( return realDir; }; + +export const clearSafeOutputDir = async ( + outputDir: string, + options: ResolveOptions = {} +): Promise => { + const safeDir = await resolveSafeOutputDir(outputDir, options); + await rm(safeDir, { recursive: true, force: true }); + return safeDir; +}; diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts index 91a0bdf36..30660f463 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts @@ -73,6 +73,18 @@ describe("toolkit data loader", () => { }); }); + it("rejects malformed toolkit index entries", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "index.json"), + JSON.stringify({ toolkits: [{ category: "development" }] }), + "utf-8" + ); + + await expect(readToolkitIndex({ dataDir: dir })).resolves.toBeNull(); + }); + }); + it("finds toolkit data by docsLink slug when file name doesn't match", async () => { await withTempDir(async (dir) => { // File is named posthogapi.json (normalized) but we look up by the diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts index 74d7a4017..d62910789 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts @@ -146,6 +146,24 @@ describe("getToolkitSlug", () => { expect(getToolkitSlug(source)).toBe("gmail"); }); + it("falls back when docsLink contains an unsafe encoded slug", () => { + expect( + getToolkitSlug({ + id: "UnsafeToolkit", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }) + ).toBe("unsafe-toolkit"); + }); + + it("falls back for prototype-like docsLink slugs", () => { + expect( + getToolkitSlug({ + id: "Github", + docsLink: "https://docs.arcade.dev/__proto__", + }) + ).toBe("github"); + }); + it("converts mixed-case IDs to kebab-case in fallback", () => { const source: ToolkitSlugSource = { id: "GoogleDrive", @@ -170,4 +188,8 @@ describe("getToolkitSlug", () => { }; expect(getToolkitSlug(source)).toBe("slack"); }); + + it("returns null when neither docsLink nor toolkit ID can form a slug", () => { + expect(getToolkitSlug({ id: "---", docsLink: null })).toBeNull(); + }); }); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts index 8c5c59f2c..6d1e3376b 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { normalizeToolkitId } from "../../../app/_lib/toolkit-slug"; import { + getToolkitCanonicalPath, getToolkitStaticParamsForCategory, listToolkitRoutes, type ToolkitCatalogEntry, @@ -26,7 +27,15 @@ const writeIndex = async ( { generatedAt: "2026-01-15T00:00:00.000Z", version: "1.0.0", - toolkits, + toolkits: toolkits.map(({ id, category }) => ({ + id, + label: id, + version: "1.0.0", + category: category ?? "others", + type: "arcade", + toolCount: 1, + authType: "none", + })), }, null, 2 @@ -57,6 +66,29 @@ describe("toolkit static params", () => { expect(normalizeToolkitId("GitHub API")).toBe("githubapi"); }); + it("rejects canonical paths for toolkit IDs without a valid slug", () => { + expect(() => + getToolkitCanonicalPath({ id: "---", category: "development" }) + ).toThrow("Cannot build a canonical path"); + }); + + it("skips toolkit files whose IDs cannot form a route slug", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "invalid.json"), + JSON.stringify({ + id: "---", + label: "Invalid", + metadata: { category: "development" }, + }) + ); + + await expect( + listToolkitRoutes({ dataDir: dir, toolkitsCatalog: [] }) + ).resolves.toEqual([]); + }); + }); + it("lists toolkit routes from the index", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [ @@ -78,6 +110,27 @@ describe("toolkit static params", () => { }); }); + it("returns toolkit routes in deterministic category and slug order", async () => { + await withTempDir(async (dir) => { + await writeIndex(dir, [ + { id: "Slack", category: "social" }, + { id: "Gmail", category: "productivity" }, + { id: "Github", category: "development" }, + ]); + + const routes = await listToolkitRoutes({ + dataDir: dir, + toolkitsCatalog: [], + }); + + expect(routes).toEqual([ + { toolkitId: "github", category: "development" }, + { toolkitId: "gmail", category: "productivity" }, + { toolkitId: "slack", category: "social" }, + ]); + }); + }); + it("uses catalog category as fallback when JSON file is absent", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [{ id: "Github", category: "development" }]); diff --git a/toolkit-docs-generator/tests/cli/generate-flow.test.ts b/toolkit-docs-generator/tests/cli/generate-flow.test.ts index bdb2044ea..12440b99a 100644 --- a/toolkit-docs-generator/tests/cli/generate-flow.test.ts +++ b/toolkit-docs-generator/tests/cli/generate-flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + assertSafeCurrentToolkitSnapshot, collectRemovedToolkitIds, computeProcessingStats, filterProvidersBySkipIds, @@ -72,6 +73,19 @@ describe("collectRemovedToolkitIds", () => { }); }); +describe("assertSafeCurrentToolkitSnapshot", () => { + it("rejects an empty current snapshot when previous output exists", () => { + expect(() => assertSafeCurrentToolkitSnapshot(0, 115)).toThrow( + "refusing to remove all 115 existing toolkits" + ); + }); + + it("allows an empty initial snapshot and non-empty updates", () => { + expect(() => assertSafeCurrentToolkitSnapshot(0, 0)).not.toThrow(); + expect(() => assertSafeCurrentToolkitSnapshot(114, 115)).not.toThrow(); + }); +}); + describe("computeProcessingStats", () => { it("ignores skip IDs that do not exist in the fetched toolkit list", () => { const toolkitList = new Map([ diff --git a/toolkit-docs-generator/tests/generator/output-verifier.test.ts b/toolkit-docs-generator/tests/generator/output-verifier.test.ts index ada7865fb..4322e817b 100644 --- a/toolkit-docs-generator/tests/generator/output-verifier.test.ts +++ b/toolkit-docs-generator/tests/generator/output-verifier.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, rename, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { basename, join } from "path"; import { describe, expect, it } from "vitest"; import { @@ -410,3 +410,80 @@ describe("JsonGenerator.rebuildIndexFromOutput", () => { }); }); }); + +describe("JsonGenerator toolkit file paths", () => { + it("rejects toolkit IDs that escape the output directory", async () => { + await withTempDir(async (dir) => { + const toolkit = await loadFixture("github-toolkit.json"); + const escapeName = `${basename(dir)}-escape`; + const escapePath = join(dir, "..", `${escapeName}.json`); + const generator = createJsonGenerator({ outputDir: dir }); + + try { + await expect( + generator.generateToolkitFile({ + ...toolkit, + id: `../${escapeName}`, + }) + ).rejects.toThrow("Unsafe toolkit ID"); + await expect(readFile(escapePath, "utf-8")).rejects.toThrow(); + } finally { + await rm(escapePath, { force: true }); + } + }); + }); + + it('reserves "index" for the generated index file', async () => { + await withTempDir(async (dir) => { + const toolkit = await loadFixture("github-toolkit.json"); + const generator = createJsonGenerator({ outputDir: dir }); + + await expect( + generator.generateToolkitFile({ ...toolkit, id: "INDEX" }) + ).rejects.toThrow("reserved toolkit ID"); + await expect( + readFile(join(dir, "index.json"), "utf-8") + ).rejects.toThrow(); + }); + }); + + it("rejects case-insensitive toolkit ID collisions before writing", async () => { + await withTempDir(async (dir) => { + const toolkit = await loadFixture("github-toolkit.json"); + const generator = createJsonGenerator({ outputDir: dir }); + + const result = await generator.generateAll([ + toolkit, + { ...toolkit, id: toolkit.id.toUpperCase() }, + ]); + + expect(result.filesWritten).toEqual([]); + expect(result.errors).toEqual([ + `Toolkit IDs collide on github.json: ${toolkit.id}, ${toolkit.id.toUpperCase()}`, + ]); + await expect( + readFile(join(dir, "github.json"), "utf-8") + ).rejects.toThrow(); + await expect( + readFile(join(dir, "index.json"), "utf-8") + ).rejects.toThrow(); + }); + }); + + it("sorts index entries by normalized toolkit ID", async () => { + await withTempDir(async (dir) => { + const [githubToolkit, slackToolkit] = await Promise.all([ + loadFixture("github-toolkit.json"), + loadFixture("slack-toolkit.json"), + ]); + const generator = createJsonGenerator({ outputDir: dir }); + + await generator.generateAll([slackToolkit, githubToolkit]); + + const index = JSON.parse( + await readFile(join(dir, "index.json"), "utf-8") + ) as { toolkits: Array<{ id: string }> }; + expect(index.toolkits.map(({ id }) => id)).toEqual(["Github", "Slack"]); + }); + }); +}); diff --git a/toolkit-docs-generator/tests/merger/data-merger.test.ts b/toolkit-docs-generator/tests/merger/data-merger.test.ts index 70b0afe0c..eb4d69177 100644 --- a/toolkit-docs-generator/tests/merger/data-merger.test.ts +++ b/toolkit-docs-generator/tests/merger/data-merger.test.ts @@ -1928,7 +1928,8 @@ describe("DataMerger", () => { const results = await merger.mergeAllToolkits(); const result = results[0]; - // Error result must preserve previous custom sections, not return empty arrays + expect(result?.error).toBe("Custom sections source unavailable"); + expect(result?.toolkit).toEqual(previousResult.toolkit); expect(result?.toolkit.documentationChunks).toHaveLength(1); expect(result?.toolkit.documentationChunks[0]?.content).toBe( "Critical: GitHub Apps only." @@ -1955,6 +1956,7 @@ describe("DataMerger", () => { const results = await merger.mergeAllToolkits(); const result = results[0]; + expect(result?.error).toBe("Custom sections source unavailable"); expect(result?.toolkit.documentationChunks).toHaveLength(0); expect(result?.toolkit.customImports).toHaveLength(0); expect(result?.toolkit.subPages).toHaveLength(0); @@ -2053,6 +2055,27 @@ describe("DataMerger", () => { expect(results[0]?.toolkit.id).toBe("Github"); }); + it("fails strict runs when a complete toolkit cannot be merged", async () => { + const toolkitDataSource = createCombinedToolkitDataSource({ + toolSource: new InMemoryToolDataSource([githubTool1]), + metadataSource: new InMemoryMetadataSource([githubMetadata]), + }); + const merger = new DataMerger({ + toolkitDataSource, + customSectionsSource: { + getCustomSections: async () => { + throw new Error("Custom sections source unavailable"); + }, + }, + toolExampleGenerator: createStubGenerator(), + requireCompleteData: true, + }); + + await expect(merger.mergeAllToolkits()).rejects.toThrow( + "Failed to process Github: Custom sections source unavailable" + ); + }); + it("should return empty array when no tools", async () => { const toolkitDataSource = createCombinedToolkitDataSource({ toolSource: new InMemoryToolDataSource([]), diff --git a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts index 273ff1e19..98dbe62b4 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -270,6 +270,51 @@ describe("buildToolkitInfoList", () => { expect(entry?.category).toBe("sales"); }); + it("maps unknown categories to the safe others directory", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "../../outside", + docsLink: "https://docs.arcade.dev/integrations/unsafe", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.category).toBe("others"); + }); + + it("falls back when docsLink does not end in a safe slug", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafe-toolkit"); + }); + + it("uses the shared normalized fallback for legacy toolkit IDs", () => { + createToolkitJson("unsafe", { + id: "Unsafe Toolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafetoolkit"); + }); + it("should dedupe entries that share a docsLink slug", () => { createToolkitJson("upclickapi", { id: "UpclickApi", @@ -505,6 +550,22 @@ describe("generateCategoryMeta", () => { expect(result).toContain('title: "Test \\"Quoted\\" Label"'); }); + it("should escape backslashes and newlines in labels", () => { + const toolkits: ToolkitInfo[] = [ + { + id: "test", + slug: "test", + label: "Test\\Label\nNext", + category: "others", + navGroup: "optimized", + }, + ]; + + const result = generateCategoryMeta(toolkits, "others", "/preview"); + + expect(result).toContain('title: "Test\\\\Label\\nNext"'); + }); + it("should handle empty array", () => { const result = generateCategoryMeta([], "productivity", "/preview"); diff --git a/toolkit-docs-generator/tests/sources/custom-sections-file.test.ts b/toolkit-docs-generator/tests/sources/custom-sections-file.test.ts index c4a68192c..1e4af1936 100644 --- a/toolkit-docs-generator/tests/sources/custom-sections-file.test.ts +++ b/toolkit-docs-generator/tests/sources/custom-sections-file.test.ts @@ -53,6 +53,25 @@ describe("CustomSectionsFileSource", () => { expect(result?.toolChunks).toEqual({}); }); + it("loads rich subpage entries supported by generated toolkit output", async () => { + tempDir = await createTempDir(); + const filePath = join(tempDir, "custom-sections.json"); + const subPage = { + type: "mdx", + content: "# Setup", + relativePath: "setup/page.mdx", + }; + await writeFile( + filePath, + JSON.stringify({ Github: { subPages: [subPage] } }, null, 2) + ); + + const source = createCustomSectionsFileSource(filePath); + const result = await source.getCustomSections("Github"); + + expect(result?.subPages).toEqual([subPage]); + }); + it("throws a helpful error when JSON is invalid", async () => { tempDir = await createTempDir(); const filePath = join(tempDir, "invalid.json"); @@ -87,4 +106,19 @@ describe("CustomSectionsFileSource", () => { `Custom sections file has invalid schema (${filePath})` ); }); + + it("rejects malformed rich subpage entries", async () => { + tempDir = await createTempDir(); + const filePath = join(tempDir, "invalid-subpage.json"); + await writeFile( + filePath, + JSON.stringify({ Github: { subPages: [{ type: "mdx" }] } }, null, 2) + ); + + const source = createCustomSectionsFileSource(filePath); + + await expect(source.getAllCustomSections()).rejects.toThrow( + `Custom sections file has invalid schema (${filePath})` + ); + }); }); diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index 6e75e049d..99d93ee31 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -11,7 +11,11 @@ import { InMemoryToolDataSource, } from "../../src/sources/in-memory.js"; import type { IMetadataSource } from "../../src/sources/internal.js"; -import { createCombinedToolkitDataSource } from "../../src/sources/toolkit-data-source.js"; +import { + createCachedToolkitDataSource, + createCombinedToolkitDataSource, + type IToolkitDataSource, +} from "../../src/sources/toolkit-data-source.js"; import type { ToolDefinition, ToolkitMetadata } from "../../src/types/index.js"; const createTool = ( @@ -406,3 +410,42 @@ describe("CombinedToolkitDataSource", () => { expect(lookedUpIds).toEqual([]); }); }); + +describe("createCachedToolkitDataSource", () => { + it("reuses one immutable all-toolkit snapshot within a run", async () => { + const snapshot = new Map([ + [ + "Github", + { + tools: [createTool()], + metadata: createMetadata(), + }, + ], + ]); + const githubData = snapshot.get("Github"); + if (!githubData) { + throw new Error("Expected GitHub fixture"); + } + let fetchAllCalls = 0; + const source: IToolkitDataSource = { + fetchToolkitData: async () => githubData, + fetchAllToolkitsData: async () => { + fetchAllCalls += 1; + return snapshot; + }, + isAvailable: async () => true, + }; + const cached = createCachedToolkitDataSource(source); + + const [first, second, third] = await Promise.all([ + cached.fetchAllToolkitsData(), + cached.fetchAllToolkitsData(), + cached.fetchAllToolkitsData(), + ]); + + expect(fetchAllCalls).toBe(1); + expect(first).toBe(snapshot); + expect(second).toBe(first); + expect(third).toBe(first); + }); +}); diff --git a/toolkit-docs-generator/tests/utils/output-dir.test.ts b/toolkit-docs-generator/tests/utils/output-dir.test.ts index 6f58f95c2..44a85e416 100644 --- a/toolkit-docs-generator/tests/utils/output-dir.test.ts +++ b/toolkit-docs-generator/tests/utils/output-dir.test.ts @@ -1,8 +1,12 @@ import { mkdir, mkdtemp, realpath, rm } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { basename, join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveSafeOutputDir } from "../../src/utils/output-dir.js"; +import { + clearSafeOutputDir, + resolveDefaultOutputDir, + resolveSafeOutputDir, +} from "../../src/utils/output-dir.js"; describe("resolveSafeOutputDir", () => { const originalCwd = process.cwd(); @@ -65,4 +69,62 @@ describe("resolveSafeOutputDir", () => { }) ).rejects.toThrow("unsafe output directory"); }); + + it("rejects the repository root when run from the generator directory", async () => { + const generatorDir = join(repoRoot ?? "", "toolkit-docs-generator"); + await mkdir(generatorDir); + process.chdir(generatorDir); + + await expect(clearSafeOutputDir(repoRoot ?? "")).rejects.toThrow( + "unsafe output directory" + ); + expect(await realpath(repoRoot ?? "")).toContain( + basename(repoRoot ?? "repo") + ); + }); + + it("clears a safe output directory", async () => { + const outputDir = join(repoRoot ?? "", "output"); + await mkdir(outputDir, { recursive: true }); + const expected = await realpath(outputDir); + + const cleared = await clearSafeOutputDir(outputDir, { + repoRoot: repoRoot ?? undefined, + homeDir: homeDir ?? undefined, + }); + + expect(cleared).toBe(expected); + await expect(realpath(outputDir)).rejects.toThrow(); + }); + + it("does not clear a relative directory outside the repository", async () => { + const outsideName = `${basename(repoRoot ?? "repo")}-outside`; + const outsideDir = join(repoRoot ?? "", "..", outsideName); + await mkdir(outsideDir); + try { + await expect( + clearSafeOutputDir(`../${outsideName}`, { + repoRoot: repoRoot ?? undefined, + homeDir: homeDir ?? undefined, + }) + ).rejects.toThrow("outside repo root"); + expect(await realpath(outsideDir)).toContain(outsideName); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); +}); + +describe("resolveDefaultOutputDir", () => { + it("uses the generator data directory from the repository root", () => { + expect(resolveDefaultOutputDir("/repo")).toBe( + "/repo/toolkit-docs-generator/data/toolkits" + ); + }); + + it("uses the local data directory from the generator directory", () => { + expect(resolveDefaultOutputDir("/repo/toolkit-docs-generator")).toBe( + "/repo/toolkit-docs-generator/data/toolkits" + ); + }); }); diff --git a/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts b/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts index 96e11de2d..599d14365 100644 --- a/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts +++ b/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts @@ -20,6 +20,9 @@ test("porter workflow includes required triggers", () => { }); test("porter workflow generates docs and opens a PR", () => { + expect(workflowContents).toContain("pnpm run toolkit-docs:check"); + expect(workflowContents).not.toContain("run: pnpm build"); + expect(workflowContents).not.toContain("name: Build toolkit docs generator"); expect(workflowContents).toContain("pnpm dlx tsx src/cli/index.ts generate"); expect(workflowContents).toContain("--skip-unchanged"); expect(workflowContents).toContain("--require-complete"); @@ -60,3 +63,13 @@ test("workflow dispatch keeps default full-run behavior", () => { expect(workflowContents).not.toContain("inputs.providers"); expect(workflowContents).not.toContain("PROVIDERS_INPUT="); }); + +test("workflow dispatch can create an isolated verification PR", () => { + expect(workflowContents).toContain("pr_branch:"); + expect(workflowContents).toContain( + "inputs.pr_branch || 'automation/toolkit-docs'" + ); + expect(workflowContents).toContain( + "inputs.pr_branch && github.ref_name || github.event.repository.default_branch || 'main'" + ); +}); diff --git a/toolkit-docs-generator/vitest.config.ts b/toolkit-docs-generator/vitest.config.ts index f7e5aeeda..3ff060c9f 100644 --- a/toolkit-docs-generator/vitest.config.ts +++ b/toolkit-docs-generator/vitest.config.ts @@ -1,6 +1,8 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), test: { // Enable globals like describe, it, expect without imports globals: true,