Files
sim/scripts/generate-docs.ts
T
0075ab9cf6 improvement(platform): remove tour, simplify sidebar/header, drop loading skeletons (#4354)
* improvement(platform): workspace UI/UX overhaul + integrations catalog

Rework the workspace around the AI-workspace model: a Mothership home, a
top-level Skills route, connected-credential and integration-detail pages,
and a polished sidebar/settings surface. Replace the notifications store
with a unified toast system (provider-level dismiss/pause, countdown ring).

Integrations & catalog:
- Add a BlockMeta layer (tags + catalog templates) scoped to catalog-visible
  integrations; every catalog integration carries >=7 grounded templates.
- Rework the taxonomy: each block declares category tools|blocks|triggers.
  3rd-party services are 'tools'; first-party primitives (postgres, mysql,
  knowledge, file, search, stt/tts, image/video generators, thinking, etc.)
  are 'blocks'. Versioned blocks follow the upgrade paradigm (old hidden,
  latest in toolbar/docs).
- Generate integrations.json + tool docs canonically from block configs.

Architecture & cleanup:
- Consolidate block data extraction behind a single latest-version strategy
  (getCanonicalBlocksByCategory; version-consistent getBlockMeta).
- Unify version-suffix handling in @sim/utils/string (stripVersionSuffix /
  isVersionedType, with tests); registry, generate-docs, tools/utils, and
  integrations all route through it.
- Repair latent broken barrels, remove dead code, fix BlockMeta-related type
  errors and 5 broken docs links.

Behavior-preserving for block execution and the toolbar's tool/block listing.

* refactor(platform): remove forms, templates, and creators features

Remove three standalone features and their supporting code:
- Forms: form-deployment pages, API routes, execution path, and docs.
- Templates: the template gallery (landing + workspace) and template APIs.
- Creators: creator-profile routes and contracts.

Add a super-user permissions module (lib/permissions/super-user) and an
organizations API contract; update the audit/db/testing packages, billing,
and the session/theme providers accordingly.

* test(workflows): update archiveWorkflow update count after forms removal

The forms feature was removed, dropping the form-table update from
archiveWorkflow. Update the stale assertion from 8 to 7 tx.update calls.

* upgrade

* improvement(knowledge): polish tag filter dropdowns (#4816)

* improvement(logs): object storage backed tracespans (#4787)

* improvement(logs): obj storage backed tracespans

* fix storage write context

* fix tests

* address comments

* address comments

* chore(db): remove migration 0219 to regenerate after staging merge

Drops the 0219_robust_shard SQL, its snapshot, and the journal entry so the
trace-spans/cost schema migration can be regenerated on top of the latest
staging migration chain (avoids a number collision with staging's migrations).

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(billing): accurate per-member usage via shared ledger helper

Per-member/per-user usage in the org-member routes now adds the usage_log
ledger to the currentPeriodCost baseline (which is no longer incremented),
via a shared getOrgMemberLedgerByUser helper to avoid repeating the
subscription→period→ledger lookup across the admin and member-facing routes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* regen migrations

* update migration

* address comments

* more code cleanup

* incorrect type cast

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* improvement(providers): harden OpenAI-compatible providers + add tests (#4796)

* improvement(providers): harden OpenAI-compatible providers + add tests

* fix(vllm): let tool-loop errors propagate instead of returning silent partial success

* fix(litellm): force tool_choice 'none' on final structured-output call

The deferred final call used tool_choice 'auto', so the model could emit
another tool_calls round instead of the structured answer, leaving content
stale. Use 'none' (matching vLLM/Fireworks) on both the streaming and
non-streaming final calls so the model must return the structured response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(providers/ollama): drop tools from post-tool streaming call

Ollama ignores tool_choice (not in its supported fields), so vLLM/Fireworks'
tool_choice:'none' guard is a no-op here. Omit tools from the final streaming
payload instead so the summarization turn can't emit dropped tool calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(litellm): spread payload into deferred final call so reasoning_effort carries over

The non-streaming deferred finalPayload hand-picked fields and dropped
reasoning_effort (and any future payload field), diverging from the streaming
path which spreads ...payload. Spread payload here too for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(providers/ollama): restore enrichment TSDoc block

Keeps parity with sibling Chat Completions providers (cerebras/mistral/xai).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(fireworks): restore TSDoc on utils helpers

Restore the TSDoc blocks on supportsNativeStructuredOutputs,
createReadableStreamFromOpenAIStream, and checkForForcedToolUsage —
TSDoc is the codebase documentation standard and should not have been
stripped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(litellm): remove inline rationale comments (codebase uses TSDoc)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(providers/ollama): drop orphaned enrichment TSDoc

The block documented a function that now lives in trace-enrichment.ts, so it
documents nothing in this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(copilot): deprecate mcp server (#4797)

* chore(copilot): deprecate mcp

* update error codes

* deprecate copilot api v1 route

* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza (#4777)

* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza

Add hosted-key support across all credit-consuming Findymail, Prospeo, and Wiza operations so Sim provides the key when a workspace has not brought its own. Register the three BYOK providers, consolidate Wiza's two-step reveal into a single polling wiza_individual_reveal op, and hide the API key field on hosted Sim for hosted operations.

* fix(integrations): harden Wiza reveal polling, soften enrichment getCost guards

Address Greptile + Cursor Bugbot review on #4777: return explicit failures from the Wiza individual_reveal poller instead of throwing (thrown errors were swallowed into a false queued success), short-circuit when the initial reveal is already terminal, tolerate transient 5xx/429 during polling, and return 0 (not throw) from Findymail getCost when the contacts/employees array is absent.

* chore(integrations): biome formatting after wiza merge resolution

* fix(wiza): type isTerminalReveal param structurally for next build typecheck

* feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall

* feat(enrichments): add Wiza + Prospeo phone reveal to phone-number waterfall

* feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades

* fix(tables): reduce column header chevron size and fix sidebar shadow bleed (#4800)

* feat(slack): add install + privacy section to integration landing page (#4799)

* feat(slack): add install + privacy section to integration landing page

Adds a hand-authored, slug-keyed landing-content module (separate from the generated integrations.json so it survives regeneration) and renders an install walkthrough + privacy-policy link on integration pages when present. Also refreshes generated docs (data-enrichment entry, icon mappings, tool mdx).

* fix(landing): render privacy section independently, align CTA analytics label

* docs(landing): clarify the Slack install button is behind sign-in

* refactor(landing): bake integration landing content into generated json via docs-gen

Moves landing content (install walkthrough + privacy) out of a render-time augment and into the generation pipeline: generate-docs reads the pure-data content map and writes landingContent into integrations.json, so the page reads a single source (integration.landingContent). Canonical types live in integrations/data/types.ts.

* improvement(enrichments): align enrichments sidebar with design system (#4801)

* improvement(enrichments): align enrichments sidebar with design system

* fix(enrichments): consistent close button pattern and fix url link hover

* fix(misc): upgrade path change for new better-auth version, billing issue for workflow block agent usage (#4803)

* fix(misc): upgrade path change for new better-auth version, double-billing for workflow block agent usage

* fail loudly if stripe sub id missing

* fix(copilot): seq migration (#4804)

* chore(db): drop redundant idx_webhook_on_workflow_id_block_id index (#4809)

Removed because (workflow_id, block_id) is a left-prefix of idx_webhook_on_workflow_id_block_id_updated_at_desc, which fully covers it. The dropped index was non-unique and enforced no constraint.

* perf(copilot): read chat transcripts from copilot_messages (R+1 cutover) (#4808)

* perf(copilot): read chat transcripts from copilot_messages, not JSONB

Flip user-facing chat reads from the legacy copilot_chats.messages JSONB
array (5.7GB, 99% TOAST) to the normalized copilot_messages table via a
new loadCopilotChatMessages helper ordered by seq NULLS LAST, created_at,
id — the verified canonical order. Both chat-detail getters
(getAccessibleCopilotChat, getAccessibleCopilotChatWithMessages) now drop
the messages column from their metadata select (no more whole-array
detoast on every load) and assemble the transcript from the table after
authorization. This cascades to the copilot + mothership GET endpoints
and to resolveOrCreateChat's conversationHistory (the LLM payload).

The normalize/effective-transcript pipeline is source-agnostic
(copilot_messages.content == a JSONB array element), so transcripts are
byte-identical. Dual-write and the JSONB column stay in place as the
internal-logic source and fallback; removing JSONB writes is a later step.

Prod integrity verified before cutover: 0 messages missing, 0 NULL-seq,
0 dup keys/seq, 0 orphans, order-parity vs JSONB = 0 mismatches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(copilot): cover auth-deny on a found row skips the messages query

Address PR review: exercise the `if (!authorized) return null` contract —
when the chat row exists but authorization fails, the getter returns null
and never issues the copilot_messages read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tables): right-align run/stop in embedded toolbar; workflow cells format like normal cells (#4806)

* fix(tables): right-align run/stop in the embedded table toolbar

Add a right-aligned `trailing` slot to ResourceOptionsBar and move the embedded
mothership table's run/stop control into it, so Filter + Sort stay left-aligned
and run/stop sits opposite on the right. No-op for the search-bearing consumers
(logs, resource list), which don't pass `trailing`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tables): workflow-output cells format values like normal cells

Workflow-output columns short-circuited in resolveCellRender and rendered their
value as plain text, so a sim-resource URL / external URL / JSON / date produced
by a workflow never got the chip, favicon link, or typed formatting a normal
cell gets. Factor value formatting into a shared `resolveValueKind` helper used
by both the workflow-value branch and the plain-cell branch; the workflow branch
keeps the typewriter reveal for plain streaming text via a `typewriter` flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tables): detect resource/URL links on workflow output regardless of column type

Workflow output columns default to `json` (columnTypeForLeaf), so routing their
values through the type-based formatter (a) gated chip/URL promotion behind
`column.type === 'string'` — a URL produced by a json-typed output never became
a chip — and (b) JSON.stringify'd plain string values, adding quotes and losing
the typewriter reveal. Detect links (sim-resource chip / favicon URL) on the
value string directly for workflow outputs, falling back to the plain `value`
kind; plain cells keep the type-based formatting. Addresses Greptile P2 on #4806.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(icons): repair broken integration icon rendering (#4810)

* fix(icons): repair broken integration icon rendering

Two distinct bugs left integration icons broken on the /integrations page
(visible at 32-40px, hidden at the toolbar's 16px):

1. Corrupted SVG paths (Notion, Greptile, Granola, Calendly, Grafana, Bedrock):
   over-minified data dropped elliptical-arc flag digits (e.g. `A1 1 0 5.9 7`
   instead of `A1 1 0 0 0 5.9 7`); Granola's cubic stream was truncated. Browsers
   abort path parsing at the first invalid arc flag, so each rendered as a fragment
   or blank. Replaced with correct path data from canonical sources, preserving each
   icon's existing fill/gradient and bgColor.

2. Invisible glyph (Bright Data): its icon uses fill='currentColor' but bgColor was
   '#FFFFFF', and every surface forces text-white on the glyph - white-on-white.
   Changed bgColor to Bright Data's brand blue (#3d7ffc) so the white glyph reads,
   matching the white-glyph-on-brand-chip convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(icons): restore Calendly dual-tone brand colors

Addresses review feedback: the previous fix replaced the broken Calendly icon
with a monochrome #006BFF path, dropping the cyan #0ae8f0 accent from the
original dual-tone mark. Restored the two-tone logo (blue + cyan) using clean,
valid path data, cropped to a tight square viewBox so it fills the chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* improvement(icons): enlarge icons, fix Zoom contrast and Quiver chip

- Zoom: glyph was blue-on-blue (#0B5CFF on #2D8CFF chip); switched to
  currentColor so it renders as a white glyph on the blue chip.
- Quiver: chip bgColor #000000 -> #FFFFFF to match the icon's near-white box,
  and enlarged the mark slightly (viewBox crop).
- Enlarged (tightened viewBox, verified no clipping): RevenueCat, Prospeo,
  Granola, Firecrawl, Enrich.so, and the AWS icons (RDS, DynamoDB, SQS,
  CloudFormation, Athena, CloudWatch, SES, Bedrock, S3).
- ZoomInfo left unchanged: it is a full red rounded-square logo that already
  fills its frame, so a crop would clip it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(icons): use Bright Data wordmark on white chip; repair Circleback

- Bright Data: replaced the flame glyph with the official two-tone 'bright data'
  wordmark (provided asset), centered in a symmetric viewBox. Reverted the chip
  bgColor from #3d7ffc to #FFFFFF since the blue wordmark is invisible on a blue
  chip (the wordmark is designed for a light background).
- Circleback: a minifier had rounded the pattern's image scale to scale(0),
  collapsing the embedded logo to zero size (invisible). Restored the correct
  scale (1/280 = 0.00357142857) so the C. mark renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(docs): sync Quiver block color card to white chip

Reflects the Quiver bgColor change (#000000 -> #FFFFFF) in the docs block info card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* improvement(icons): enlarge AWS/Cloudflare/Dagster icons, fully white Zoom

- Enlarged (tighter viewBox, render-verified, no clipping): Cloudflare, Dagster,
  and the red AWS icons AWS IAM, Identity Center, Secrets Manager, SES, STS.
  Identity Center was anomalously small (filled ~32% of its frame); the group is
  now sized consistently (~80% fill).
- Zoom: the camera lens triangle was still #0B5CFF (blue-on-blue); switched it to
  currentColor so the whole camera renders white on the blue chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(wiza): consolidate individual reveal into a single operation

Merges the separate Start/Get Individual Reveal operations into one Individual
Reveal operation in the Wiza docs and integrations data (operationCount 5 -> 4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* improvement(icons): size remaining AWS icons to match the set (~80% fill)

Bring RDS, DynamoDB, SQS, CloudFormation, Athena, CloudWatch and S3 up to the
same ~80% fill as the AWS IAM/Identity Center/Secrets Manager/SES/STS group, so
all AWS icons are visually consistent. Bedrock left as-is (already ~92% fill).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(icons): use Bright Data flame mark, enlarge ZoomInfo

- Bright Data: the full 'bright data' wordmark was illegible at chip size.
  Replaced with just the flame-'i' brand mark (blue #4280f6 on the white chip),
  centered.
- ZoomInfo: cropped the viewBox toward the white 'Zi' so it's larger; the red
  rounded-square background still fills the chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* improvement(icons): enlarge CrowdStrike icon

The falcon mark sat small in its chip because the icon used a wide 768x500
viewBox (letterboxed in the square chip). Switched to a square viewBox centered
on the mark so it fills ~80%, consistent with the other icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tables): serialize schema mutations to prevent parallel column clobber (#4812)

* Make workflow description nullable

* fix(tables): serialize schema mutations to prevent parallel column clobber

* fix(tables): load workflow outside schema lock; use DbOrTx for getTableById

* fix(tables): scale idle timeout in updateColumnType to avoid aborting large type changes

* fix(tables): skip stale remap types when workflowId changes concurrently

* fix(tables): scale idle timeout in updateColumnConstraints for large tables

* fix(wait): resume live/draft async waits and preserve cell context on chained waits (#4814)

* Make workflow description nullable

* fix(wait): resume live/draft async waits and preserve cell context on chained waits

* improvement(knowledge): polish tag filter dropdowns

* improvement(knowledge): soften filter section labels

* improvement(knowledge): soften list filter labels

* fix(security): harden SSO domain registration, webhook path isolation, and CSV export (#4813)

* fix(security): harden KB file access, SSO domain registration, webhook path isolation, env secrets, and CSV export

* fix(sso): scope domain conflict query with indexed lower(domain) filter

Address PR review: avoid a full-table scan on every SSO provider
registration by filtering candidate rows in SQL with
lower(domain) = <normalized>, keeping the in-memory ownership check.
Also tighten the normalizeSSODomain TSDoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: condense env route security comments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* icons update

* chore(security): tighten inline comments in CSV export and KB file authorization

Condense verbose comment blocks to concise TSDoc/single-line form; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): validate internal serve origin in KB file authorization

Replace the bypassable isInternalFileUrl substring check in resolveInternalKbKey
with an origin allow-list (base URL, internal API base URL, TRUSTED_ORIGINS).
A crafted external host whose path is /api/files/serve/<victim-key> no longer
resolves to the victim key. Relative same-origin URLs are unaffected.

* style(sso): use idiomatic sql lower() comparison for domain conflict query

Match the repo's prevailing `sql`lower(col) = value`` idiom for the
case-insensitive SSO domain conflict lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): align workspace env admin gate with hasWorkspaceAdminAccess

Use the same admin check the secrets UI uses (owner, admin permission, or
org-admin) so owners and org-admins are not wrongly denied their own decrypted
workspace secrets, while read-only members remain restricted to names only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sso): rely on lower(domain) match for conflict detection, drop dead in-memory recheck

Address PR review: the SQL `lower(domain) = <normalized>` predicate already
excludes rows that the in-memory `normalizeSSODomain(...) === domain` recheck
claimed to catch, making that recheck dead/misleading code. Match on the
canonical lower-cased domain and filter purely by ownership. Malformed legacy
values (wildcards, schemes, ports) never match an email domain at sign-in, so
excluding them is not a gap. Test DB mock now applies the lower() predicate so
the casing-variant case is genuinely exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): scope webhook deploy path conflict to active webhooks

findConflictingWebhookPathOwner omitted the isActive filter that the
runtime dispatcher (findAllWebhooksForPath) applies, so an inactive but
non-archived webhook from another workflow (e.g. after undeploy or
failure auto-disable) would permanently block any new deployment on that
path even though it never receives deliveries. Align the guard with the
runtime isActive + archivedAt filter; the earliest-owner runtime check
remains the authoritative cross-tenant protection. Also trims verbose
TSDoc on the webhook path-isolation helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): exclude archived workflows from webhook deploy path conflict

findConflictingWebhookPathOwner now joins workflow and filters
isNull(workflow.archivedAt), matching the runtime dispatcher
(findAllWebhooksForPath). A webhook on an archived workflow can never
receive deliveries at runtime, so it must not block legitimate path reuse
with a 409.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): anchor KB file ownership to earliest document in any state

A KB file's owner is now the earliest document referencing its key regardless of
state (active/archived/deleted/excluded); access is granted only when that owning
document is still active. Closes the residual where an attacker could plant an
active document to claim a file whose original document was archived or deleted.

* updated greptile icon

* revert(security): drop KB file authorization changes

Reverts the knowledge-base file-access work (origin-pinning / owner-pinning /
origin allow-list in verifyKBFileAccess) and its test. The other hardening fixes
(SSO domain registration, webhook path isolation, workspace env secrets, CSV
export) are unchanged. apps/sim/app/api/files/authorization.ts is restored to its
origin/staging baseline.

* fix(sso): treat caller's own user-scoped provider as owned during conflict check

Self-hosters often register SSO user-scoped via the CLI script (no
SSO_ORGANIZATION_ID). If they later enable organizations and reconfigure the
same domain org-scoped through the UI, the conflict check previously treated
their own user-scoped row as another tenant's and returned a misleading 409.
Recognize the caller's own user-scoped provider as owned so that migration is
allowed, while still blocking another user's or another org's domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert(security): remove workspace-env admin gate

Defer to a credential-based access model (separate change). Restores
GET /api/workspaces/[id]/environment to main behavior and removes the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(security): consolidate webhook path-collision check into one helper

Extract findConflictingWebhookPathOwner to lib/webhooks/utils.server.ts as
the single source of truth for cross-tenant path-collision detection, used by
both webhook creation paths (deploy sync and the manual /api/webhooks route).

This also repairs two latent issues in the manual route's previous inline
check, which queried with limit(1) and only webhook.archivedAt:
- limit(1) inspected one arbitrary row, so a same-workflow row could mask a
  foreign collision (false negative). The shared helper scans all matching
  rows.
- It omitted isActive/workflow.archivedAt, so inactive or archived-workflow
  webhooks (which never receive deliveries) permanently blocked path reuse.
  The helper mirrors the runtime dispatcher's filter.

Same-workflow webhook reuse for upsert is now a separate, explicit lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): block private/reserved IPs for hosted 1Password Connect SSRF (#4818)

* fix(security): block private/reserved IPs for hosted 1Password Connect SSRF

* test(security): use real isPrivateOrReservedIP and cover IPv6 edge cases

* improvement(integrations): validate and expand devin, cursor, and greptile (#4820)

* improvement(integrations): validate and expand devin, cursor, and greptile

- devin: fix missing org_id path segment on all session endpoints, add 7 session sub-resource tools (list messages/attachments, get/append/replace tags, archive, terminate), pagination, and is_archived output
- cursor: add get_api_key_info, list_models, list_repositories tools
- greptile: align block and docs
- normalize array outputs to default [] and tighten types

* refactor(cursor): simplify list_repositories v2 array normalization

Collapse the redundant `?? []` + `Array.isArray` double-guard into a
single Array.isArray check, per PR review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(devin): scope session-tag mapping to tag ops and normalize array tag inputs

- Only map sessionTags into the tools tags param for append/replace operations,
  preventing stale sessionTags state from clobbering create_session tags
- Fall back to a wired tags value when sessionTags is empty for tag operations
- Normalize tag inputs (string or wired string[]) via normalizeTags so array
  values from other blocks no longer throw on .split

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cursor): restore base64 file data in legacy download_artifact metadata

The legacy CursorBlock exposes only content + metadata (no v2 file
output), so metadata.data was the only way legacy-block workflows could
access downloaded artifact bytes. Restore the base64 data field and
document it in the outputs/type instead of dropping it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(devin): coerce terminateArchive to archive flag for boolean-wired input

* docs(integrations): regenerate tool docs for new devin and cursor operations

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(search-replace): don't auto-navigate when content edits invalidate the active match (#4819)

* fix(search-replace): don't auto-navigate when content edits invalidate the active match

* fix(search-replace): clear afterReplaceIndexRef on apply failure and zero matches

* fix(search-replace): remove duplicate setActiveSearchTarget(null) on close

* fix(search-replace): move afterReplaceIndexRef write inside handleApply past the guard

* fix(search-replace): auto-navigate when hydration resolves with no prior active match

* chore(search-replace): remove inline comments

* fix(search-replace): revert !activeMatchId guard that caused immediate re-navigation after deselect

* improvement(enrichments): limit company-info to fields both providers return (#4817)

Hunter's company dataset returns null industry/foundedYear for many large companies (verified against the live API for Microsoft, Amazon, Google), so under the first-non-empty-wins cascade those columns appeared inconsistently across rows. Limit company-info outputs to employee count and description — the fields Hunter and PDL both reliably return — so every row is consistent. employeeCount is a string so Hunter's range bucket and PDL's exact count share the column.

* fix(files): don't reject external URLs containing '..' in file parse validation (#4821)

* fix(files): don't reject external URLs containing '..' in file parse validation

The file block's file_fetch operation rejected any external URL whose path
contained '..' (e.g. Slack files-pri slugs with a literal '...') with
'Access denied: path traversal detected'. Traversal checks only apply to
local paths — external http(s) URLs are fetched with SSRF protection
downstream and are never resolved against the filesystem, so they now
short-circuit as valid. Internal /api/files/serve/ URLs keep full traversal
protection.

* test(files): fix external-URL assertion to handle undefined error

* test(files): assert success explicitly in external-URL traversal test

* fix(files): keep traversal protection for https URLs matching internal serve paths

* feat(google-sheets): add row filtering to read with numeric operators (#4822)

* feat(google-sheets): add row filtering to read with numeric operators

Adds client-side row filtering to the Google Sheets read (v2) operation.
Filter the returned rows by a header column using text operators
(contains, not_contains, exact, not_equals, starts_with, ends_with) and
numeric/ordering operators (gt, gte, lt, lte). Filtering lives in a pure,
unit-tested helper (filterSheetRows) and runs over the fetched read range;
an optional `filter` output reports whether the column was found and how
many rows matched.

Also hardens the surrounding tools:
- trim spreadsheetId in write/update/append URL builders (matches read)
- URL-encode the v1 read default range
- expose valueInputOption for the update operation in the block

Backwards compatible: with no filter requested, read output is byte-
identical and the `filter` field is omitted. The filterMatchType union is
widened additively (4 -> 10 values).

* fix(google-sheets): correct filter metadata for missing column and header-only sheets

- matchedRows is now 0 (not totalRows) when the filter column is not found,
  so it no longer contradicts applied=false / columnFound=false
- columnFound now reflects an actual header lookup for empty/header-only
  sheets instead of being hardcoded true
- add tests covering header-only and empty sheets with present/absent columns

* fix(selectors): fetch all pages for paginated dropdown list routes (#4823)

* fix(selectors): fetch all pages for paginated dropdown list routes

Dropdown selectors fetched only the first page of paginated provider
APIs, silently hiding results past page one. Add bounded server-side
draining to the list routes across Microsoft Graph, Google, Notion,
Atlassian, Linear, AWS CloudWatch, and offset/token REST APIs, plus a
shared client-side drain cap in the selector hook. Response shapes,
stored values, and tool execution are unchanged; CloudWatch list tools
still honor a caller-supplied limit. Also fixes the Word file picker
that was searching for .xlsx files.

* fix(selectors): harden JSM and Monday pagination draining

- JSM service-desk/request-type drains advance `start` by the actual row
  count returned (not the fixed page size) and stop on an empty page, so a
  short non-final page can't skip items.
- Monday boards drain now checks `response.ok` per page, surfacing a
  mid-drain HTTP failure instead of treating it as an empty final page and
  returning a partial 200.

* docs(selectors): clarify JSM drain advances start by actual row count

The offset-advancement fix (advance `start` by the rows returned, not the
fixed page size) landed in 7b19788a8; update the TSDoc to match so it no
longer reads as advancing by `limit`.

* fix(selectors): drain fetchPage in direct fetchList callers

Making `fetchList` optional left three direct callers (outside the
useSelectorOptions hook) calling it unguarded, which broke the build's
type check. Route them through a shared `loadAllSelectorOptions` helper
that uses `fetchList` when present and otherwise drains `fetchPage`.
This also prevents a regression: `confluence.spaces` / `knowledge.documents`
now paginate via `fetchPage` only, and these callers (search/replace,
value resolution) would otherwise have silently returned no options.

* chore(selectors): rename MAX_PAGE_PAGES to MAX_NOTION_PAGES for readability

* fix(sso): re-check domain conflict before write and reject IP-address domains (#4825)

* improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write (#4826)

Stop writing/reading the legacy copilot_chats.messages JSONB column now that
reads are cut over to copilot_messages. Make appendCopilotChatMessages the
primary write (throws on failure instead of swallowing), repoint peripheral
readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to
copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's
transaction so it commits atomically with the stream-marker clear. The column
itself is dropped in a follow-up migration after this bakes.

* feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) (#4827)

Add does-not-contain ($ncontains), starts-with ($startsWith), ends-with
($endsWith), not-in-array ($nin, previously executed server-side but unexposed
in the UI), and is-empty/is-not-empty ($empty) filter operators end-to-end —
SQL builder, condition types, query-builder converters/constants, the filter
UI, the Table tools/block descriptions, and docs.

Also fix correctness bugs in the filter builder surfaced by the wider operator
set:
- Same-column AND rules (e.g. age > 18 AND age < 65, or name startsWith 'A'
  AND name endsWith 'Z') silently overwrote each other because the AND group
  was keyed by column name. They now merge into one operator object, which
  also makes Filter -> rules -> Filter round-trip losslessly for multi-operator
  columns.
- $nin values were not split into an array like $in, and textual-match values
  like "123" were numeric-coerced (breaking the ILIKE path).
- A non-boolean $empty operand from the raw API silently inverted the check; it
  now coerces 'true'/'false' strings and otherwise returns a 400.

* improvement(copilot): stop persisting tool-call result outputs in transcripts (#4829)

Opening a Mothership task could take many seconds because a single persisted
assistant message in copilot_messages.content can reach hundreds of MB, almost
entirely inside contentBlocks[].toolCall.result.output (e.g. a get_workflow_logs
or run_workflow result). The DB query is ~2ms; the cost is detoasting that
payload, shipping it to the browser, and parsing it.

These outputs are dead weight on the Sim side: they are never rendered (the
thread shows only tool name/title/status) and never replayed to the model (the
upstream copilot service owns conversation memory). So drop result.output before
it is persisted, keeping result.success/error plus the tool metadata.

- add stripToolResultOutput() in persisted-message.ts
- apply it in messages-store toRow (covers every write path) and in
  loadCopilotChatMessages (existing rows render fast on read)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(providers): add Together AI, Baseten, and Ollama Cloud model providers (#4830)

* feat(providers): add Together AI, Baseten, and Ollama Cloud model providers

* fix(providers): guard Ollama streaming fast-path with hasActiveTools

Match Together/Baseten/Fireworks: when tools are supplied but all are
filtered out (usageControl 'none'), take the single streaming call instead
of an extra non-streaming round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(providers): filter non-chat model types from Together model list

* refactor(providers): dedupe Ollama Cloud upstream schema

ollamaCloudUpstreamResponseSchema was byte-for-byte identical to
ollamaUpstreamResponseSchema (both /api/tags endpoints return the same
{ models: [{ name }] } shape). Drop the duplicate and reuse the shared schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(knowledge): calendar view sync, deduplicate popover animation classes, type-safe filter cast

* cleanup(knowledge): remove TRIGGER_BORDER_CLASS duplication, inline displayLabel, drop enabledFilterParam alias

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>

* feat(blocks): add BlockMeta to Quiver and Linq; fix invalid block config fields; update skills

Block fixes:
- Add QuiverBlockMeta (tags + 3 templates: icon generator, diagram creator, vectorizer)
- Fix QuiverBlock: remove invalid tags field from BlockConfig, IntegrationType.Design →
  IntegrationType.AI (Design doesn't exist in the enum)
- Fix GreptileBlock: remove invalid tags field from BlockConfig,
  IntegrationType.DeveloperTools → IntegrationType.DevOps
- Fix LinqBlock: remove invalid tags field from BlockConfig (tags belong only in BlockMeta)

Skills:
- add-block: add dedicated BlockMeta section with structure, rules, and registration
  pattern; add BlockMeta checklist items
- add-integration: add BlockMeta to block structure template, add rules clarifying
  that tags must NOT appear on BlockConfig and integrationType must be a valid enum
  value; update registry snippet to include blocksMeta; add checklist items

* fix(integrations): fix category dropdown by defining missing LANDING_INTEGRATIONS_DATA_PATH and regenerating integrations.json

The staging merge introduced landing-content.ts but forgot to define
LANDING_INTEGRATIONS_DATA_PATH in generate-docs.ts, causing the script
to crash before writing integrations.json.

The stale JSON had integrationTypes (plural array) from an older script
version, while the Integration type and workspace UI both read
integrationType (singular string) — so ALL_CATEGORY_SECTIONS bucketed
to undefined and the category filters never appeared in the dropdown.

Fixed by adding the missing path constant and re-running the generator.
integrations.json now has 192 entries with the correct integrationType field.

* fix(sidebar): restore resize handle on all pages

commit 3109104582 wrapped the resize handle in {(isCollapsed ||
isOnWorkflowPage) && ...} and added a useEffect that resets sidebar
width to SIDEBAR_WIDTH.MIN whenever the user navigates away from a
workflow page. Together these made the sidebar non-resizable on Tasks,
Tables, Knowledge Base, and every other non-workflow page.

Restore the staging behavior: always render the resize handle and
remove the effect that forced the width reset on page transitions.

* fix(sidebar): match staging onKeyDown and tabIndex on resize handle

The resize handle was still conditionalizing onKeyDown and tabIndex
on isCollapsed, blocking keyboard accessibility of the separator role
when expanded. Staging always attaches both unconditionally.

onKeyDown={isCollapsed ? handleEdgeKeyDown : undefined} → onKeyDown={handleEdgeKeyDown}
tabIndex={isCollapsed ? 0 : undefined}                 → tabIndex={0}

* feat(integrations): show connected credentials on integration detail page

When navigating to /integrations/google-docs (or any integration), a
Connected section now appears above the templates listing all workspace
credentials tied to that provider. Each row links back to the credential
detail page (/integrations/connected/${id}) for management actions.

Pairs with the earlier change that routes connected items from the
integrations list to the provider detail page instead of directly to
the credential detail page.

* fix(integrations): rename Add in chat to Add to Sim

* fix(skills): rename Add button to Add to Sim

* fix(platform): restore M1/M2/M3 regressions and LazyMotion on landing page

M1 — Invitation guard: re-introduce usePermissionConfig().isInvitationsDisabled
alongside the workspace inviteDisabledReason check. The flag now also
respects NEXT_PUBLIC_DISABLE_INVITATIONS and EE permission-group
disableInvitations, not just billing policy.

M2 — Settings redirects: /settings/integrations and /settings/skills
now server-redirect to /integrations and /skills respectively so old
bookmarks and emails don't silently land on General.

M3 — Starter block search exclusion: restore block.type !== 'starter'
guard in the search store so users cannot add a duplicate Starter block
via the command palette.

LazyMotion: restore LazyMotion + domMax/domAnimation wrappers and m.*
components in landing-preview-panel and landing-preview-home. The
removal was accidental (the full motion bundle was left after an
import cleanup), which caused the entire framer-motion feature set to
load eagerly on the landing page.

* fix(integrations): revert connected list to credential detail; remove settings redirects

* feat(sidebar): restore workspace switcher search with updated styling

Shows a search input in the workspace dropdown when the user has more
than 3 workspaces (WORKSPACE_SEARCH_THRESHOLD). Keyboard navigation:
ArrowDown/Up to move through results, Enter to switch, resets on close.

Styled to match the current branch (border-1/surface-5 tokens, sm text,
11px Search icon) rather than the old staging styles. Highlight state
is wired through chipVariants active prop so it follows the same active
appearance as clicked/hovered items.

* fix(sidebar): clean up workspace search — layout, memo, and effect guard

* fix(sidebar): align workspace rename input selection style with workflow rename

* perf(sidebar): eliminate React re-renders during sidebar drag resize

Previously, every mousemove during resize called setSidebarWidth(), which
both updated the --sidebar-width CSS variable (sync) and set Zustand state
(async). This caused:
  1. A 1-frame transition flash on mousedown — isResizing state had to
     round-trip through React before the is-resizing CSS class was applied,
     so the width transition fired for the first pixel of movement.
  2. A React re-render per pixel dragged — components reading sidebarWidth
     from the store (avatars, usage-indicator) lagged one frame behind the
     container, making the + and ... buttons appear to jump ahead.

New approach:
  - handleMouseDown adds is-resizing directly to the sidebar DOM node before
    any React involvement (synchronous, no frame lag).
  - mousemove writes only to the CSS custom property (zero React renders).
  - mouseup persists the final width to Zustand exactly once.
  - isResizing / setIsResizing state removed from the store and hook — they
    are no longer needed since the class is managed via direct DOM mutation.

* perf(sidebar): add requestAnimationFrame throttle to resize mousemove handler

* fix(sidebar): fix drag-right lag caused by WorkspaceChrome overflow-hidden transition

The sidebar-container's is-resizing class correctly suppressed its own width
transition, but the two wrapper divs in WorkspaceChrome both have
transition-[width]/transition-transform with a 175ms ease. The outer wrapper
also has overflow-hidden, so while the sidebar content was at the correct width
instantly, it was visually clipped by the outer wrapper which was still
animating — causing the + and ... buttons to appear to lag behind the resize
line on drag-right (not on drag-left, since shrinking doesn't clip content).

Fix: add sidebar-shell-outer/sidebar-shell-inner class names to both chrome
wrappers, and suppress their transitions via html.sidebar-resizing rule when
a drag is active. The html.sidebar-resizing class is toggled directly in the
resize hook alongside is-resizing, so it takes effect synchronously on mousedown.

* fix(icons): redesign Download icon to match Upload style; fix Upload/Download confusion

Download icon was missing the tray/shelf line at the bottom that Upload has,
making it look like a plain arrow rather than a matched pair. Updated Download
to use the same viewBox, stroke weight, and three-path structure as Upload
(tray + stem + arrowhead), just pointing down.

Also fix 5 places where Upload (↑) was incorrectly used for download/export
actions:
  - files.tsx: two Download action rows in toolbar and context menu
  - tables/table.tsx: Export CSV toolbar button
  - table-context-menu.tsx: Export CSV context menu item
  - logs.tsx: Export toolbar button
  - landing-preview-logs.tsx: decorative Export button

Import CSV and actual upload actions correctly keep the Upload icon.

* fix(icons): replace Upload with Download on all remaining export/download actions

- panel.tsx: Export workflow dropdown item
- context-menu.tsx: Export in sidebar workflow context menu
- chat.tsx: Export chat button
- output-panel.tsx: Export console CSV button
- terminal.tsx: Export console CSV button
- resource-content.tsx: Export table as CSV + Download file buttons

* fix(icons): fix remaining Upload→Download on download actions in files and logs

- action-bar.tsx: download button in files toolbar
- file-row-context-menu.tsx: Download item in file context menu
- file-download.tsx: both download buttons in log details file viewer

* updated block skills, settings pages, modals, buttons -> chips, blocks missing metadata

* updated skill modal

* improvement(resource-header): refine breadcrumb truncation ux

* improvement(resource): add floating overflow text tooltips

* wire up credits counter

* improvement(resource-header): mute path dropdown title

* refactor(resource-header): share floating-tooltip engine, prune dead overlay tooltips (#4844)

Clean up the breadcrumb truncation feature for reuse and correctness:

- Extract useFloatingTooltip / useIsOverflowing / FloatingTooltip into a shared
  floating-tooltip module. BreadcrumbSegment and FloatingOverflowText now consume
  one implementation instead of duplicating ~150 lines of positioning, velocity,
  overflow-detection, and portal logic.
- Replace the hardcoded terminal-label regex in ResourceHeader with a typed
  `terminal` flag on BreadcrumbItem (set by the document chunk/loading crumbs),
  decoupling the generic header from knowledge-base copy.
- Clear the path-popover close timeout on unmount and reuse the shared
  POPOVER_ANIMATION_CLASSES constant.
- Drop the redundant manual overflow-state writes (fixes a sticky fade mask).
- Revert FloatingOverflowText inside Combobox `overlayContent` back to plain
  truncating spans across files/logs/tables/scheduled-tasks/document: the combobox
  overlay is pointer-events-none, so the tooltip handlers never fired there.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(emcn,resource-header): address PR #4844 review feedback

- useIsOverflowing now uses a callback ref so the ResizeObserver follows the
  element across mount/unmount/reassignment instead of capturing it once at mount.
  Safe for conditionally rendered consumers of the shared hook. (greptile P2)
- Move POPOVER_ANIMATION_CLASSES out of chip-date-picker implementation internals
  into emcn/components/popover/popover-animation.ts, exported from the
  @/components/emcn barrel. Consumers now import from the module boundary.
  (greptile P2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* upgrade table and styling upgrade

* fix schema to include integration

* fix(files): align delete icon with tables view (Trash → Trash2)

Co-Authored-By: waleed <waleed@simstudio.ai>

* fix(mothership): preserve blockType for integration contexts in sent messages

Integration mention chips were missing their provider icons in sent messages
because blockType was dropped when mapping ChatContext to messageContexts.
renderIntegrationTile returns null without blockType, silently hiding the icon.

* fix(mothership): allow 'integration' resource type in chat resources API

The VALID_RESOURCE_TYPES allowlist was missing 'integration', causing a
400 error when adding integrations to the Mothership resource tab — so
they never persisted and disappeared on refresh.

* fix(ui): Add "File" title next to file resource header

* fix(ui): fix resource header columns being bolded

* fix(resource): keep the floating tooltip from jumping on click

Gate the focus-driven show behind :focus-visible so a mouse click (which
focuses the trigger) no longer re-shows the tooltip anchored to the element's
bottom edge. On click the tooltip now hides cleanly instead of jumping down;
keyboard focus still shows it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(sidebar): eliminate unnecessary re-renders in workspace switcher for non-search users

- onMouseEnter: only set highlightedIndex when showSearch is true, preventing
  a state update + re-render on every workspace row hover for users with ≤ 3
  workspaces where the search is never shown
- onOpenChange: only reset workspaceSearch and highlightedIndex when showSearch
  is true, since both values are always already at their defaults for non-search
  users and setting them triggers a pointless re-render during dropdown close
- data-workspace-row-idx: only set when showSearch is true since the scroll
  effect that reads this attribute is already gated on showSearch

* feat(search): context-aware cmd-k results on the integrations page

When cmd-k is opened on the integrations page, show two new result
groups: connected accounts (visible even with empty input) and catalog
integrations (appear once the user types). Selecting an OAuth integration
deep-links to its detail page with ?connect=oauth so the connect modal
auto-opens. Non-OAuth integrations navigate to the plain detail page.

Both groups are gated to the integrations page only and respect the
hideIntegrationsTab permission. The credentials fetch shares the same
React Query cache key as the integrations page itself (no double fetch).

* refactor(emcn): make the floating tooltip the one canonical Tooltip

Replace the Radix-based emcn Tooltip with the cursor-following floating tooltip so
every tooltip in the app uses one consistent style. Built on the shared
floating-tooltip engine (relocated into emcn), not a parallel implementation.

- Move the floating-tooltip engine into emcn/components/tooltip and export it from
  the barrel; re-point its consumers (FloatingOverflowText, resource-header)
- Extend the FloatingTooltip bubble to render arbitrary children (+ role/id for
  a11y) so it can back general tooltips, not just overflow text
- Rebuild emcn Tooltip (Root/Trigger/Content/Provider/Shortcut/Preview) on
  useFloatingTooltip — compound API preserved, ~350 call sites unchanged, legacy
  side/align props accepted and ignored (the tooltip follows the cursor). Removes
  @radix-ui/react-tooltip usage (package kept for a later cleanup; react-slot
  retained for asChild)

Note: general tooltips now show instantly (no hover delay) and follow the cursor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(emcn): put tooltip text on the design scale (text-caption)

Replace the tooltip's ad-hoc `text-xs` + `leading-[18px]` with the semantic
`text-caption` (12px) font-size token so the text styling is fully on the design
scale and self-documenting, matching how the rest of the system is set up. The
color already used the global `--text-body` token. No visual change (still 12px
with a ~18px line height).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sidebar): add empty task state and inline task creation

- Show "No tasks yet" in the Tasks section (expanded and collapsed) when the list is empty
- Clicking + now creates a task via the API and navigates directly to it, rather than navigating to home
- Add isCreatingTaskRef guard to prevent double-click from spawning multiple tasks
- Disable + button while creation is pending
- Fall back to home navigation on creation error

* invite, billing, home

* improvement(seats): auto purchase seats on invitations into workspace (#4857)

* improvement(seats): auto purchase seats on invitations into workspace

* improve sampling for seat drift reconciler

* address comments

* feat(knowledge): align connector UI with integrations page styling

- ConnectorTypeCard now matches integration rows: brand-colored rounded-xl tile, ArrowRight, title/subtitle hierarchy
- ConnectorCard icon upgraded from flat surface-4 to branded tile (white icon on brand bg, graceful fallback)
- Connector header badges use chipVariants instead of custom Button classes
- Add-connector search input aligned to integrations style (h-[30px], rounded-lg, border-1)

* fix(icons): trim Folder SVG viewBox to remove right-side whitespace

The folder path only extends to x≈14.33 in a 15-unit viewBox, leaving
~0.5 units of empty space on the right. At 12px rendered size this
produces ~0.4px extra gap (visible as ~1px on retina displays) compared
to solid icons like the workflow color square. Trimming the viewBox to
14.5 units makes the folder fill its chip slot evenly.

* fix(user-input): restore draft text synchronously to preserve contexts on nav

The SSR-safe approach (empty useState + effect restore) created a timing
window where the sync effect in useContextManagement fired with message=''
before the value was set, clearing any restored contexts. Folder and workflow
contexts (not re-added by applyAutoMentions) were lost on every nav-back.

Revert to the staging approach: initialize value synchronously from the
draft store so message is already populated when effects run, matching
the behavior on staging.

* fix(queue): render context chips in queued messages

- Remove plainMentions from queued message rows so context chips render
  with icons, consistent with sent messages
- Fix computeMentionRanges to use '/' prefix for skill contexts (content
  has the slash trigger restored at submit time, not '@')

* fix(mothership): remove integrations from add-resource dropdown

* fix(mothership): comment out integrations from add-resource dropdown

* chore(db): drop form, templates, template_creators, template_stars tables

These tables backed the Forms and Templates platform features which were
intentionally removed from this branch. Clean up the DB schema to match.

* chore(db): add migration metadata for 0224 drop tables

* block icons, sidebar, toolbar

* chore: remove remaining dead code for template-profile feature

- Remove 'template-profile' from SettingsSection union type
- Remove 'template-profile' entry from SECTION_TITLES
- Remove now-redundant template-profile guard in settings sidebar
- Remove commented-out template-profile nav item

* fix(multi-select): preserve anchor on range selection for tasks and folders

After a shift+click range, the anchor (lastSelectedTaskId / lastSelectedFolderId)
was being updated to the end of the range (toId). This caused subsequent
shift+clicks to extend from the wrong point instead of the original click.

Standard behavior: anchor stays at the initial click (fromId) so repeated
shift+clicks always expand/contract relative to where you started.

* feat(emcn): add SearchInput component and unify search bars platform-wide

- Add SearchInput to emcn: 30px chip-family filled search input matching the
  integrations page pattern (border-1, surface-5, leading Search icon)
- Migrate all 22 search bars across settings, EE pages, and integrations to
  SearchInput (only layout classes allowed at callsites)
- Rename Sim Keys -> Sim API Keys in nav/title; page copy now says API key
- Remove components/ui input, label, and verified-badge; migrate consumers
  to emcn equivalents or raw inputs (table cell editor, wand prompt bar)
- Delete dead EE skeleton files (data-drains, data-retention)
- General settings: Home Page chip moves to header left as navigation

* fix(files,tables): restore new-file editor autofocus and CSV import error toasts

Both were dropped on the staging line and regressed vs production (main):

- Files: the new-file editor autofocus chain (files.tsx -> file-viewer ->
  text-editor) was stripped by the react-doctor dead-code pass in #4544,
  which misread the prop-drilled `autoFocus` (consumed by an imperative
  `editor.focus()` effect) as unused. Restored the prop through all three
  layers and the one-shot focus effect so creating a new file focuses the
  editor immediately.
- Tables: CSV import failures were silently logged with no user feedback.
  Restored the per-file and generic `toast.error` surfacing.

* feat(home): score suggested actions by workspace signals

- Derive the suggestion pool from the curated block template catalog
  (1,343 prompts across 172 blocks) instead of 15 hardcoded entries
- Fix inverted relevance: prompts for connected providers are now boosted
  4x (instantly runnable) instead of excluded; unconnected discounted 0.4x
- Weight by featured (3x), popular category (1.5x), and resource gaps
  (no tables -> boost table starters; has KBs -> dampen KB-creation prompts)
- Weighted sampling without replacement, max one suggestion per block
- Connect rows weighted by catalog template count; 2 for fresh workspaces,
  1 once something is connected
- Key the catalog map by both versioned and base block types so gmail_v2
  templates resolve (gmail, github, notion, linear were silently dropped)
- Replace derive-in-effect state with a useMemo keyed by a shuffle nonce
- Add suggested_action_clicked / suggested_actions_shuffled /
  suggested_actions_toggled PostHog events

* billing, teammates

* improvement(credentials): credentials invites, secrets tab wiring up (#4874)

* improvement(credentials): move away from invite notion

* wire up secrets ui/ux

* address comments

* get consistent styling by removing emcninput + text area

* styling consistency

* remove fallback

* address comment:

* refactor(ui): migrate settings & workspace UI to chip design system

Migrate modals to ChipModal (showDivider, hint, resizable, size, leading,
ChipModalTabs), standardize Chip variants, add ChipCombobox wrapper, and apply
chip inputs/dropdowns across settings, knowledge, logs, tables, inbox, EE tabs.
Render ChipModalTabs as a ChipSwitch segmented control. Align /settings/secrets
detail with /integrations, refresh whitelabeling, and restore file-editor
autofocus and CSV import error toasts.

* fix(mothership): restore integrations to useAvailableResources for @ mention

Integrations were fully removed from useAvailableResources which broke
the @ mention menu since user-input shares the same hook. Now integrations
are always included in the hook but excluded at the AddResourceDropdown
component level, keeping them out of the sidebar + menu while remaining
available for @ mention autocomplete.

* refactor(settings): chip design-system consistency pass across all tabs

Extract a shared chip-field shell (CHIP_FIELD_SHELL/CHIP_FIELD_INPUT) mirroring
Input variant='chip' and route secrets, credential detail, and integrations
credential detail through it (30px height, font-medium, focus ring). Add a
Discard action to the secrets header when dirty. Group BYOK providers into
Models/Search & web/Enrichment sections and align its tiles to the integrations
tile.

Normalize list-row typography to text-[14px]/text-[12px] and icon tiles to
rounded-xl + border across api-keys, copilot, custom-tools, mcp,
workflow-mcp-servers, credential-sets, and access-control. Tone the secrets
Details chip and per-row affordances to ghost. Fix token correctness: raw
tailwind colors to design tokens (data-drains), missing chip variant on the
Snowflake role input, ColorInput chip-field reuse (whitelabeling), error token
and icon sizes (workflow-mcp-servers), border token (mcp), no-results sizing
(secrets), row chrome (recently-deleted), hover token and Button to Chip
(access-control), and deduped textarea chrome (sso).

* feat(settings): unify filter dropdowns on ChipSelect (integrations style)

Add a ChipSelect emcn component — a filled chip trigger + chevron opening a
DropdownMenu, matching the integrations category filter — supporting single
select, multi-select (checkbox rows), grouped options, and optional in-menu
search. Migrate every settings/EE filter dropdown off ChipCombobox to it:
audit-logs (resource-type multi + time-range), data-retention, data-drains,
general, admin (grouped tool picker), inbox status filter, and the workflow
MCP-server pickers. The SSO provider-id field stays an editable combobox since
it accepts free-text slugs.

Also fix audit findings: chip the MCP client-secret input, normalize an MCP
error-text size, and drop now-dead destination-icon code in data-drains.

* fix(mentions): require explicit @ for integration mentions; decorate sent messages robustly

- Bare integration names in prose (Monday, Notion, Clay) are no longer
  auto-converted to mentions or chipped — mention treatment is strictly
  opt-in via a token-starting @ (fixes the scunthorpe problem)
- @-prefixed mentions still canonicalize casing (@slack -> @Slack) on both
  the keystroke fast-path and bulk paths (paste, template, draft, STT)
- Sent/queued messages now self-sufficiently decorate @IntegrationName
  tokens via a text scan, covering messages sent before the input pass
  ran or authored outside the chat input
- Integration contexts missing a resolvable blockType (messages persisted
  before blockType was saved) are backfilled by label lookup so their
  mention pills render the brand icon again

* refactor(settings): section the API keys page like secrets

Wrap Workspace, Personal, and the allow-personal-keys toggle in SettingsSection
(muted label + divider) instead of bare bold headers, matching the secrets and
BYOK pages.

* fix(settings): ChipSelect renders above modals + full-width form mode

Raise the ChipSelect menu to --z-popover so it layers above modal surfaces
(--z-modal) instead of opening behind them. Add a fullWidth prop that stretches
the trigger and right-aligns the chevron for form-field use, and apply it to the
workflow MCP-server pickers.

* fix(emcn): ChipSelect uses the emcn flat chevron, not lucide's square one

The lucide ChevronDown is square; rendering it at the chip's 9x7 footprint
stretched it. Switch to the custom emcn ChevronDown (built for that wide aspect),
matching the integrations filter and ChipDropdown.

* fix(emcn): ChipSelect trigger hugs its content (w-fit)

In a stacked form layout the trigger was stretched by align-items: stretch,
leaving an empty gap to the right of the value. Add w-fit so the chip sizes to
its content (a compact pill) everywhere; fullWidth form selects are unaffected.

* fix(emcn): ChipSelect uses a square lucide chevron

Revert to lucide's ChevronDown sized square (size-[14px]) so it renders crisp,
matching the standard select chevron used by Combobox.

* renamed tasks to chats

* rename and file change

* improvement(billing): wire up billing, org, teammates tabs + remove deprecated subscription tab (#4887)

* improvement(billing): wire up billing, org, teammates tabs + remove depr subscription tab

* pass exec timeout to tool routes

* reuse helper

* address comments

* address disable comment

* chore(db): remove migration 0224 to regenerate on top of staging

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix type errors and regen migration?

* chore(db): drop branch migration 0226 ahead of staging merge; will regenerate

* chore(db): regenerate migration 0226 after staging merge

* externalize before compaction in fallback'

* fix save/discard chips to be consistent

* fix(ui): remove smodal tabs in favor of chip modal tabs

* fix(ui): skip auto-scrolling on mouse highlight of workspace

* fix(platform): restore settings redirects, forgot-password Enter submit, and tag tooltip visibility

- Re-add SETTINGS_REDIRECTS so /settings/integrations and /settings/skills
  deep links redirect to their top-level routes instead of rendering an
  empty settings panel (accidentally removed in 86da193cc3 one minute
  after cca5054cf6 added it)
- Add opt-in onSubmit to ChipModalField input/email variants and wire it
  in the forgot-password modal so Enter submits again (lost in the
  ChipModal conversion)
- Knowledge tag tooltip: drop the max-h/overflow-y-auto clamp that the
  pointer-events-none floating tooltip made unreachable; truncate each
  tag row instead so all tags stay visible with bounded height

* chore(db): remove migration 0226_third_spot before staging merge

* chore(db): regenerate migration as 0227 after staging merge

* feat(telemetry): add posthog + audit coverage for new platform actions

Audit log (compliance/permission-relevant only):
- org_seat.provisioned — seat auto-purchased when an invite acceptance
  grows the org (actor = accepting user, includes seat delta)
- org_plan.converted — Pro→Team conversion triggered by invite acceptance
- org_seat.drift_reconciled — hourly cron healed a drifted seat count
- credential_member.added/removed/role_changed — credential sharing
  surface was previously fully unaudited
- table.created — parity with existing table.updated/deleted
- skill updates now record skill.updated instead of mislabeled
  skill.created

PostHog:
- seats_provisioned, credential_shared/unshared,
  environment_updated/deleted (key counts only, never names/values)
- table_import_started/completed — background CSV imports previously had
  zero failure observability
- table_exported, file_downloaded, skill_updated
- credential_connected now fires for OAuth completions (draft-hooks),
  credential_deleted for OAuth disconnects; previously only manual
  credentials were tracked
- table_workflow_run gains deployment_mode (live/deployed/mixed)

Also: logger.warn on all new credential-admin 403 denials (members +
environment routes); invite-created audit enriched with
enforcedFixedSeats/plan.

Deliberately excluded as noise: per-hour dead-letter audit rows (would
re-record the same stuck event every cron run) and a duplicate
system-actor org_plan.converted in the Stripe outbox handler.

* feat(home): fill textarea on suggested prompt click instead of sending

Clicking a prompt action in the Suggested Actions panel now populates
the Mothership user-input textarea (via applyAutoMentions) and focuses
it with the caret at end, rather than immediately submitting. The user
can review, edit, and send manually.

* fix(templates): name owning integration in featured block template prompts

Four featured prompts omitted their owning integration name, making them
unbranded and disconnected from their title. Each prompt now explicitly
names GitHub or Google Sheets so mentionifyIntegrations renders the chip
and the copy reads as a self-contained agent-building instruction.

* fix(templates): rewrite fragment prompts so each names its integration and reads as a complete instruction

Featured and non-featured block template prompts that either omitted
the owning integration's canonical name or were phrased as marketing
fragments rather than natural user instructions have been rewritten.
Each updated prompt now starts with an imperative verb ("Build a
workflow that…"), names the owning integration explicitly so the
@-mention chip renders correctly, and aligns with the entry's title.

Files changed: salesforce, hubspot (×2), github, slack, airtable,
firecrawl, iam.

* fix(templates): name integration in remaining block template prompts

- google_docs.ts: replace 'Google Doc' with 'Google Docs document' in 4 prompts; update title 'Meeting notes to Google Doc' to 'Meeting notes to Google Docs'
- google_sheets.ts: replace 'Google Sheet' with 'Google Sheets spreadsheet/Google Sheets' in 3 non-featured prompts
- slack.ts: replace 'Google Doc' with 'Google Docs document' in 'Daily standup summary'
- stripe.ts: replace 'Google Sheet'/'Slacks' with 'Google Sheets'/'Slack' in 'Weekly metrics report'
- reddit.ts: add 'Reddit' to 3 prompts that only referenced subreddits
- notion.ts: rewrite featured prompt to start with a verb and name Notion
- jira.ts: rewrite featured marketing-fragment prompt to start with a verb
- linear.ts: rewrite featured marketing-fragment prompt to start with a verb
- gmail.ts: rewrite featured marketing-fragment prompt to start with a verb and name Gmail

* fix(icons): convert monochrome dark brand icons to currentColor for dark mode

LinkupIcon, InfisicalIcon, IntercomIcon, LumaIcon, GranolaIcon, OnePasswordIcon,
and RailwayIcon were hardcoded to black/near-black fills/strokes, making them
invisible in bare DARK mode. Convert all to currentColor so they follow the
theme-aware text color.

Add iconColor: '#286efa' to IntercomBlock (Intercom brand blue is a confident
mid-tone, safe on both themes).

Two-tone icons (StagehandIcon, AgentPhoneIcon, QuiverIcon) are left unchanged
because their white details are structural — converting to single-tone would
destroy logo legibility.

* removed color, user input, sidebar, suggested actions, chips/emcn

* removed color migration

* improve(blocks): audit block catalog metadata for accuracy and fill gaps

- Fix template prompts that claimed capabilities blocks don't have
  (fabricated triggers, non-existent tools) across ~98 integrations
- Normalize integration tags to family conventions and valid union values
- Align alsoIntegrations and modules with template prompt content
- Add BlockMeta for circleback, imap, and rss trigger integrations
- Add templates to clickhouse and greptile metas
- Remove duplicate PageSpeed deploy-gate template

* chore(telemetry): drop org_seat.drift_reconciled audit

System self-heal bookkeeping doesn't belong in the user-facing audit
trail — membership and seat-purchase changes are already audited, and
the cron's logger output covers ops visibility.

* chore(db): consolidate branch migrations into single 0227

* fix(emcn): make ChipModal scroll internally when content exceeds viewport

The Modal→ChipModal migration dropped the old ModalBody scroll container:
ChipModal renders ModalContent bare (no overflow-hidden) and its wrappers
had no min-h-0 chain, so tall modals (e.g. New data drain with an S3
destination) overflowed max-h-[84vh] off-screen with no way to scroll.

Complete the flex min-h-0 chain through ChipModal's frame and give
ChipModalBody flex-1 min-h-0 overflow-y-auto — header/footer stay pinned,
body scrolls only when constrained. Short modals are unaffected (max-h
caps, it doesn't stretch), and dropdowns inside the body are Radix-portaled
so the new scroll container cannot clip them. Five modals that had locally
patched this with max-h-[Nvh] overrides keep working unchanged.

* fix(emcn): don't close modal when dismissing a dropdown via outside click

Radix dispatches pointer-down-outside to every open dismissable layer at
once, so clicking outside an open dropdown/select inside a modal closed
both the dropdown and the modal in one jarring step. ModalContent now
prevents its own dismissal while a portaled popper layer is open — the
first outside click closes just the popper, the next one closes the
modal.

* docs(skills): de-duplicate and correct agent docs; canonical styling tokens; mirror sim-sandbox rule

* docs(skills): broaden boundary-raw-fetch scope note, sync cursor rule mirrors

* fix(emcn): harden modal popper guard and exempt caret-anchored dropdowns from body scroll

Adversarial review follow-ups to the ChipModal scroll + dismissal fixes:

- Popper guard now requires data-state="open" inside the popper wrapper,
  so a dropdown that is merely animating closed no longer swallows the
  next outside click on the modal (DropdownMenu has an exit animation
  that keeps its wrapper mounted briefly)
- Port the same guard to SModalContent for consistency
- custom-tool-modal: opt its body out of the chrome scroll container
  (flex-none + overflow-visible); the caret-anchored EnvVar/Tag
  autocomplete dropdowns are absolute-positioned inside the body and
  must spill past its bounds rather than clip against a scroll boundary

* refactor(billing): drop unnecessary useCallback wrappers from event handlers

* fix(data-drains): complete chip migration of destination forms and fill missing placeholders

Finishes the in-flight FormField → ChipModalField conversion for all
destination form specs and adds the placeholders that several inputs
never had (S3 bucket/region/access keys, Azure account key, Datadog API
key, webhook signing secret/bearer token) — the cause of the New Data
Drain modal showing placeholder-less inputs inconsistently.

* fix(emcn): broaden modal popper guard to onInteractOutside

Covers the focusOutside dismissal path too: when a popper's focus scope
unwinds on close, the transient focus shift could still dismiss the
modal (and simultaneous body pointer-events lock teardown could freeze
the page). Same data-state="open" scoping as the pointer guard.

* fix(emcn): harden modal outside interactions

* update audit mock

---------

Co-authored-by: andres <k62hc5kjst@privaterelay.appleid.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <waleed@simstudio.ai>
2026-06-06 11:39:48 -07:00

3741 lines
130 KiB
TypeScript
Executable File

#!/usr/bin/env ts-node
import fs from 'fs'
import path from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { isVersionedType, stripVersionSuffix } from '@sim/utils/string'
import { glob } from 'glob'
import type { BlockCategory } from '../apps/sim/blocks/types'
import { IntegrationType } from '../apps/sim/blocks/types'
console.log('Starting documentation generator...')
/**
* Cache for resolved const definitions from types files.
* Key: "toolPrefix:constName" (e.g., "calcom:SCHEDULE_DATA_OUTPUT_PROPERTIES")
* Value: The resolved properties object
*/
const constResolutionCache = new Map<string, Record<string, any>>()
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, '..')
const BLOCKS_PATH = path.join(rootDir, 'apps/sim/blocks/blocks')
const DOCS_OUTPUT_PATH = path.join(rootDir, 'apps/docs/content/docs/en/tools')
const ICONS_PATH = path.join(rootDir, 'apps/sim/components/icons.tsx')
const DOCS_ICONS_PATH = path.join(rootDir, 'apps/docs/components/icons.tsx')
const INTEGRATIONS_DATA_PATH = path.join(rootDir, 'apps/sim/lib/integrations')
const LANDING_INTEGRATIONS_DATA_PATH = path.join(
rootDir,
'apps/sim/app/(landing)/integrations/data'
)
const TRIGGERS_PATH = path.join(rootDir, 'apps/sim/triggers')
const TRIGGER_DOCS_OUTPUT_PATH = path.join(rootDir, 'apps/docs/content/docs/en/triggers')
/** Trigger doc pages that are hand-written and must never be overwritten. */
const HANDWRITTEN_TRIGGER_DOCS = new Set(['index', 'start', 'schedule', 'webhook', 'rss'])
/** Providers whose docs are already covered by hand-written pages. */
const SKIP_TRIGGER_PROVIDERS = new Set(['generic', 'rss'])
/**
* Maps trigger provider names (from TriggerConfig.provider) to their
* corresponding block type when the two differ. Used to resolve icon
* colours from the block registry.
*/
const PROVIDER_TO_BLOCK_TYPE: Record<string, string> = {
'microsoft-teams': 'microsoft_teams',
'google-calendar': 'google_calendar',
'google-drive': 'google_drive',
'google-sheets': 'google_sheets',
}
/** Human-readable display names for trigger providers. */
const TRIGGER_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
airtable: 'Airtable',
ashby: 'Ashby',
attio: 'Attio',
calcom: 'Cal.com',
calendly: 'Calendly',
circleback: 'Circleback',
confluence: 'Confluence',
fathom: 'Fathom',
fireflies: 'Fireflies',
github: 'GitHub',
gmail: 'Gmail',
gong: 'Gong',
'google-calendar': 'Google Calendar',
'google-drive': 'Google Drive',
'google-sheets': 'Google Sheets',
google_forms: 'Google Forms',
grain: 'Grain',
greenhouse: 'Greenhouse',
hubspot: 'HubSpot',
imap: 'IMAP',
intercom: 'Intercom',
jira: 'Jira',
lemlist: 'Lemlist',
linear: 'Linear',
'microsoft-teams': 'Microsoft Teams',
notion: 'Notion',
outlook: 'Outlook',
resend: 'Resend',
salesforce: 'Salesforce',
servicenow: 'ServiceNow',
slack: 'Slack',
stripe: 'Stripe',
telegram: 'Telegram',
twilio_voice: 'Twilio Voice',
typeform: 'Typeform',
vercel: 'Vercel',
webflow: 'Webflow',
whatsapp: 'WhatsApp',
zoom: 'Zoom',
}
if (!fs.existsSync(DOCS_OUTPUT_PATH)) {
fs.mkdirSync(DOCS_OUTPUT_PATH, { recursive: true })
}
// Ensure docs components directory exists
const docsComponentsDir = path.dirname(DOCS_ICONS_PATH)
if (!fs.existsSync(docsComponentsDir)) {
fs.mkdirSync(docsComponentsDir, { recursive: true })
}
/** Runtime set of valid `IntegrationType` values, derived from the canonical enum. */
const INTEGRATION_CATEGORY_VALUES: ReadonlySet<IntegrationType> = new Set(
Object.values(IntegrationType)
)
/**
* Defensive shape for blocks parsed out of source files. Fields stay loose
* (`string`) so the AST-style extractor can populate them progressively; the
* canonical taxonomy is enforced at the JSON-write boundary inside
* `writeIntegrationsJson`.
*/
interface BlockConfig {
type: string
name: string
description: string
longDescription?: string
category: string
integrationType?: string
bgColor?: string
outputs?: Record<string, any>
tools?: {
access?: string[]
}
operations?: OperationInfo[]
docsLink?: string
[key: string]: any
}
/**
* Find the position after the matching close delimiter for an opening delimiter.
* Assumes `content[openPos]` is the opening char (e.g. `{` or `[`).
* Returns the index one past the matching close char, or -1 if unbalanced.
*/
function findMatchingClose(
content: string,
openPos: number,
openChar = '{',
closeChar = '}'
): number {
let count = 1
let pos = openPos + 1
while (pos < content.length && count > 0) {
if (content[pos] === openChar) count++
else if (content[pos] === closeChar) count--
pos++
}
return count === 0 ? pos : -1
}
interface TriggerInfo {
id: string
name: string
description: string
}
interface TriggerConfigField {
id: string
title: string
type: string
required: boolean
description?: string
placeholder?: string
}
interface TriggerFullInfo {
id: string
name: string
description: string
provider: string
polling: boolean
outputs: Record<string, any>
configFields: TriggerConfigField[]
}
interface OperationInfo {
name: string
description: string
}
interface IntegrationEntry {
type: string
slug: string
name: string
description: string
longDescription: string
bgColor: string
iconName: string
docsUrl: string
operations: OperationInfo[]
operationCount: number
triggers: TriggerInfo[]
triggerCount: number
authType: 'oauth' | 'api-key' | 'none'
category: BlockCategory
integrationType: IntegrationType
tags?: string[]
landingContent?: Record<string, unknown>
}
/**
* Copy the icons.tsx file from the main sim app to the docs app
* This ensures icons are rendered consistently across both apps
*/
function copyIconsFile(): void {
try {
console.log('Copying icons from sim app to docs app...')
if (!fs.existsSync(ICONS_PATH)) {
console.error(`Source icons file not found: ${ICONS_PATH}`)
return
}
const iconsContent = fs.readFileSync(ICONS_PATH, 'utf-8')
fs.writeFileSync(DOCS_ICONS_PATH, iconsContent)
console.log('✓ Icons successfully copied to docs app')
} catch (error) {
console.error('Error copying icons file:', error)
}
}
/**
* Generate icon mapping from block definitions.
* Docs need hidden historical version keys so old BlockInfoCard references and
* versioned docs links still render icons, while landing only needs visible blocks.
*/
async function generateIconMapping(options: {
includeHidden: boolean
}): Promise<Record<string, string>> {
try {
console.log('Generating icon mapping from block definitions...')
const iconMapping: Record<string, string> = {}
const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort()
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
// For icon mapping, we need ALL blocks including hidden ones
// because V2 blocks inherit icons from legacy blocks via spread
// First, extract the primary icon from the file (usually the legacy block's icon)
const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports and their types
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
let match
while ((match = exportRegex.exec(fileContent)) !== null) {
const blockName = match[1]
const startIndex = match.index + match[0].length - 1
// Extract the block content
const endIndex = findMatchingClose(fileContent, startIndex)
if (endIndex !== -1) {
const blockContent = fileContent.substring(startIndex, endIndex)
// Check hideFromToolbar - skip hidden blocks for docs but NOT for icon mapping
const hideFromToolbar = /hideFromToolbar\s*:\s*true/.test(blockContent)
// Get block type
const blockType =
extractStringPropertyFromContent(blockContent, 'type') || blockName.toLowerCase()
// Get icon - either from this block or inherited from primary
const iconName = extractIconNameFromContent(blockContent) || primaryIcon
if (!blockType || !iconName) {
continue
}
// Skip trigger/webhook/rss blocks
if (
blockType.includes('_trigger') ||
blockType.includes('_webhook') ||
blockType.includes('rss')
) {
continue
}
// Get category for additional filtering
const category = extractStringPropertyFromContent(blockContent, 'category') || 'misc'
// Exclude first-party `blocks`-category primitives (except the small
// catalog-surfaced allowlist) and core/plumbing types. This mirrors
// the catalog/docs `isIntegrationBlock` predicate, with the two
// explicit `blocks`-category exceptions folded in here.
if (
(category === 'blocks' && !ICON_MAP_BLOCK_CATEGORY_ALLOWLIST.has(blockType)) ||
ICON_MAP_EXCLUDED_TYPES.has(blockType)
) {
continue
}
const isVersionedBlockType = isVersionedType(blockType)
if (!hideFromToolbar || (options.includeHidden && isVersionedBlockType)) {
iconMapping[blockType] = iconName
}
}
}
}
console.log(`✓ Generated icon mapping for ${Object.keys(iconMapping).length} blocks`)
return iconMapping
} catch (error) {
console.error('Error generating icon mapping:', error)
return {}
}
}
/**
* Write the icon mapping to the docs app
* This file is imported by BlockInfoCard to resolve icons automatically
*/
/**
* Sort strings to match Biome's organizeImports order:
* case-insensitive character-by-character, uppercase before lowercase as tiebreaker.
*/
function biomeSortCompare(a: string, b: string): number {
const minLen = Math.min(a.length, b.length)
for (let i = 0; i < minLen; i++) {
const al = a[i].toLowerCase()
const bl = b[i].toLowerCase()
if (al !== bl) return al < bl ? -1 : 1
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1
}
return a.length - b.length
}
function writeIconMapping(iconMapping: Record<string, string>): void {
try {
const iconMappingPath = path.join(rootDir, 'apps/docs/components/ui/icon-mapping.ts')
// Add bare-name aliases for versioned block types so trigger provider names resolve correctly.
// e.g. github_v2 → github, fireflies_v2 → fireflies, gmail_v2 → gmail
const withAliases: Record<string, string> = { ...iconMapping }
for (const [blockType, iconName] of Object.entries(iconMapping)) {
const baseType = stripVersionSuffix(blockType)
if (baseType !== blockType && !withAliases[baseType]) {
withAliases[baseType] = iconName
}
}
// Get unique icon names, sorted to match Biome's organizeImports
const iconNames = [...new Set(Object.values(withAliases))].sort(biomeSortCompare)
// Generate imports
const imports = iconNames.map((icon) => ` ${icon},`).join('\n')
// Generate mapping with direct references (no dynamic access for tree shaking)
const mappingEntries = Object.entries(withAliases)
.sort(([a], [b]) => a.localeCompare(b))
.map(([blockType, iconName]) => ` ${blockType}: ${iconName},`)
.join('\n')
const content = `// Auto-generated file - do not edit manually
// Generated by scripts/generate-docs.ts
// Maps block types to their icon component references
import type { ComponentType, SVGProps } from 'react'
import {
${imports}
} from '@/components/icons'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
${mappingEntries}
}
`
fs.writeFileSync(iconMappingPath, content)
console.log('✓ Icon mapping file written to docs app')
} catch (error) {
console.error('Error writing icon mapping:', error)
}
}
/**
* Extract operation options from the subBlock with id: 'operation' (if present).
* Returns { label, id } pairs — label is the display name, id is the option's id field
* (used to construct the tool ID as `{blockType}_{id}`).
* Parses the subBlocks array using brace/bracket counting to safely traverse
* the nested structure without eval or a full AST parser.
*/
function extractOperationsFromContent(blockContent: string): { label: string; id: string }[] {
const subBlocksMatch = /subBlocks\s*:\s*\[/.exec(blockContent)
if (!subBlocksMatch) return []
// Locate the opening '[' of the subBlocks array
const arrayStart = subBlocksMatch.index + subBlocksMatch[0].length - 1
const arrayEnd = findMatchingClose(blockContent, arrayStart, '[', ']')
if (arrayEnd === -1) return []
const subBlocksContent = blockContent.substring(arrayStart + 1, arrayEnd - 1)
// Iterate over top-level objects in the subBlocks array, looking for id: 'operation'
let i = 0
while (i < subBlocksContent.length) {
if (subBlocksContent[i] === '{') {
const j = findMatchingClose(subBlocksContent, i)
if (j === -1) break
const objContent = subBlocksContent.substring(i, j)
if (/\bid\s*:\s*['"]operation['"]/.test(objContent)) {
const optionsMatch = /options\s*:\s*\[/.exec(objContent)
if (!optionsMatch) return []
const optArrayStart = optionsMatch.index + optionsMatch[0].length - 1
const optArrayEnd = findMatchingClose(objContent, optArrayStart, '[', ']')
if (optArrayEnd === -1) return []
const optionsContent = objContent.substring(optArrayStart + 1, optArrayEnd - 1)
// Extract { label, id } pairs from each option object
const pairs: { label: string; id: string }[] = []
const optionObjectRegex = /\{[^{}]*\}/g
let m
while ((m = optionObjectRegex.exec(optionsContent)) !== null) {
const optObj = m[0]
const labelMatch = /label\s*:\s*['"]([^'"]+)['"]/.exec(optObj)
const idMatch = /\bid\s*:\s*['"]([^'"]+)['"]/.exec(optObj)
if (labelMatch) {
pairs.push({ label: labelMatch[1], id: idMatch ? idMatch[1] : '' })
}
}
return pairs
}
i = j
} else {
i++
}
}
return []
}
/**
* Extract a mapping from operation id → tool id by scanning switch/case/return
* patterns in a block file. Handles both simple returns and ternary returns
* (for ternaries, takes the last quoted tool-like string, which is typically
* the default/list variant). Also picks up named helper functions referenced
* from tools.config.tool (e.g. selectGmailToolId).
*/
function extractSwitchCaseToolMapping(fileContent: string): Map<string, string> {
const mapping = new Map<string, string>()
const caseRegex = /\bcase\s+['"]([^'"]+)['"]\s*:/g
let caseMatch: RegExpExecArray | null
while ((caseMatch = caseRegex.exec(fileContent)) !== null) {
const opId = caseMatch[1]
if (mapping.has(opId)) continue
const searchStart = caseMatch.index + caseMatch[0].length
const searchEnd = Math.min(searchStart + 300, fileContent.length)
const segment = fileContent.substring(searchStart, searchEnd)
const returnIdx = segment.search(/\breturn\b/)
if (returnIdx === -1) continue
const afterReturn = segment.substring(returnIdx + 'return'.length)
// Limit scope to before the next case/default to avoid capturing sibling cases
const nextCaseIdx = afterReturn.search(/\bcase\b|\bdefault\b/)
const returnScope = nextCaseIdx > 0 ? afterReturn.substring(0, nextCaseIdx) : afterReturn
const toolMatches = [...returnScope.matchAll(/['"]([a-z][a-z0-9_]+)['"]/g)]
// Take the last tool-like string (underscore = tool ID pattern); for ternaries this
// is the fallback/list variant
const toolId = toolMatches
.map((m) => m[1])
.filter((id) => id.includes('_'))
.pop()
if (toolId) {
mapping.set(opId, toolId)
}
}
return mapping
}
/**
* Scan all tool files under apps/sim/tools/ and build a map from tool ID to description.
* Used to enrich operation entries with descriptions.
*/
interface ToolMaps {
desc: Map<string, string>
name: Map<string, string>
}
async function buildToolDescriptionMap(): Promise<ToolMaps> {
const toolsDir = path.join(rootDir, 'apps/sim/tools')
const desc = new Map<string, string>()
const name = new Map<string, string>()
try {
const toolFiles = await glob(`${toolsDir}/**/*.ts`)
for (const file of toolFiles) {
const basename = path.basename(file)
if (basename === 'index.ts' || basename === 'types.ts') continue
const content = fs.readFileSync(file, 'utf-8')
// Find every `id: 'tool_id'` occurrence in the file. For each, search
// the next ~600 characters for `name:` and `description:` fields, cutting
// off at the first `params:` block within that window. This handles both
// the simple inline pattern (id → description → params in one object) and
// the two-step pattern (base object holds params, ToolConfig export holds
// id + description after the base object).
const idRegex = /\bid\s*:\s*['"]([^'"]+)['"]/g
let idMatch: RegExpExecArray | null
while ((idMatch = idRegex.exec(content)) !== null) {
const toolId = idMatch[1]
if (desc.has(toolId)) continue
const windowStart = idMatch.index
const windowEnd = Math.min(windowStart + 600, content.length)
const window = content.substring(windowStart, windowEnd)
// Stop before any params block so we don't pick up param-level values
const paramsOffset = window.search(/\bparams\s*:\s*\{/)
const searchWindow = paramsOffset > 0 ? window.substring(0, paramsOffset) : window
const descMatch = searchWindow.match(/\bdescription\s*:\s*['"]([^'"]{5,})['"]/)
const nameMatch = searchWindow.match(/\bname\s*:\s*['"]([^'"]+)['"]/)
if (descMatch) desc.set(toolId, descMatch[1])
if (nameMatch) name.set(toolId, nameMatch[1])
}
}
} catch {
// Non-fatal: descriptions will be empty strings
}
return { desc, name }
}
/**
* Detect the authentication type from block content.
* Returns 'oauth' if the block uses oauth-input credentials,
* 'api-key' if it uses a plain API key field, or 'none' otherwise.
*/
function extractAuthType(blockContent: string): 'oauth' | 'api-key' | 'none' {
if (/type\s*:\s*['"]oauth-input['"]/.test(blockContent)) return 'oauth'
if (/\bid\s*:\s*['"](?:apiKey|api_key|accessToken)['"]/.test(blockContent)) return 'api-key'
return 'none'
}
/**
* Extract the list of trigger IDs from the block's `triggers.available` array.
* Handles blocks that declare `triggers: { enabled: true, available: [...] }`.
*/
function extractTriggersAvailable(blockContent: string): string[] {
const triggersMatch = /\btriggers\s*:\s*\{/.exec(blockContent)
if (!triggersMatch) return []
const start = triggersMatch.index + triggersMatch[0].length - 1
const trigEnd = findMatchingClose(blockContent, start)
if (trigEnd === -1) return []
const triggersContent = blockContent.substring(start, trigEnd)
if (!/enabled\s*:\s*true/.test(triggersContent)) return []
const availableMatch = /available\s*:\s*\[/.exec(triggersContent)
if (!availableMatch) return []
const arrayStart = availableMatch.index + availableMatch[0].length - 1
const arrayEnd = findMatchingClose(triggersContent, arrayStart, '[', ']')
if (arrayEnd === -1) return []
const arrayContent = triggersContent.substring(arrayStart + 1, arrayEnd - 1)
const ids: string[] = []
const idRegex = /['"]([^'"]+)['"]/g
let m
while ((m = idRegex.exec(arrayContent)) !== null) {
ids.push(m[1])
}
return ids
}
/**
* Scan all trigger definition files and build a registry mapping trigger IDs
* to their human-readable name and description.
*/
async function buildTriggerRegistry(): Promise<Map<string, TriggerInfo>> {
const registry = new Map<string, TriggerInfo>()
const SKIP = new Set(['index.ts', 'registry.ts', 'types.ts', 'constants.ts', 'utils.ts'])
const triggerFiles = (await glob(`${TRIGGERS_PATH}/**/*.ts`)).filter(
(f) => !SKIP.has(path.basename(f)) && !f.includes('.test.')
)
for (const file of triggerFiles) {
try {
const content = fs.readFileSync(file, 'utf-8')
// A file may export multiple TriggerConfig objects (e.g. v1 + v2 in
// the same file). Extract all exported configs by splitting on the
// export boundaries and parsing each one independently.
const exportRegex = /export\s+const\s+\w+\s*:\s*TriggerConfig\s*=\s*\{/g
let exportMatch
const exportStarts: number[] = []
while ((exportMatch = exportRegex.exec(content)) !== null) {
exportStarts.push(exportMatch.index)
}
// If no typed exports found, fall back to simple regex on whole file
const segments =
exportStarts.length > 0
? exportStarts.map((start, i) => content.substring(start, exportStarts[i + 1]))
: [content]
for (const segment of segments) {
const idMatch = /\bid\s*:\s*['"]([^'"]+)['"]/.exec(segment)
const nameMatch = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(segment)
const descMatch = /\bdescription\s*:\s*['"]([^'"]+)['"]/.exec(segment)
if (idMatch && nameMatch) {
registry.set(idMatch[1], {
id: idMatch[1],
name: nameMatch[1],
description: descMatch?.[1] ?? '',
})
}
}
} catch {
// skip unreadable files silently
}
}
console.log(`✓ Loaded ${registry.size} trigger definitions`)
return registry
}
/**
* Write the icon mapping TypeScript file for the shared integrations data
* directory (`apps/sim/lib/integrations`). Mirrors `writeIconMapping` (the
* docs-app variant) but targets the sim app so it imports from
* `@/components/icons`. Unlike the docs variant, no bare-name aliasing is
* applied because consumers always look up by the canonical (possibly
* versioned) `integration.type` emitted into `integrations.json`.
*/
function writeIntegrationsIconMapping(iconMapping: Record<string, string>): void {
try {
if (!fs.existsSync(INTEGRATIONS_DATA_PATH)) {
fs.mkdirSync(INTEGRATIONS_DATA_PATH, { recursive: true })
}
const iconMappingPath = path.join(INTEGRATIONS_DATA_PATH, 'icon-mapping.ts')
const iconNames = [...new Set(Object.values(iconMapping))].sort(biomeSortCompare)
const imports = iconNames.map((icon) => ` ${icon},`).join('\n')
const mappingEntries = Object.entries(iconMapping)
.sort(([a], [b]) => a.localeCompare(b))
.map(([blockType, iconName]) => ` ${blockType}: ${iconName},`)
.join('\n')
const content = `// Auto-generated file - do not edit manually
// Generated by scripts/generate-docs.ts
// Maps block types to their icon component references for the integrations page
import type { ComponentType, SVGProps } from 'react'
import {
${imports}
} from '@/components/icons'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
${mappingEntries}
}
`
fs.writeFileSync(iconMappingPath, content)
console.log('✓ Integration icon mapping written')
} catch (error) {
console.error('Error writing integration icon mapping:', error)
}
}
/**
* Collect all integration entries from block definitions and write integrations.json
* to the shared integrations data directory (`apps/sim/lib/integrations`).
* Applies the same visibility filters as the docs generation pipeline.
*/
async function writeIntegrationsJson(iconMapping: Record<string, string>): Promise<void> {
try {
if (!fs.existsSync(INTEGRATIONS_DATA_PATH)) {
fs.mkdirSync(INTEGRATIONS_DATA_PATH, { recursive: true })
}
const triggerRegistry = await buildTriggerRegistry()
const { desc: toolDescMap, name: toolNameMap } = await buildToolDescriptionMap()
// Hand-authored, integration-specific landing content (install walkthrough,
// privacy blurb), keyed by slug. Imported as pure data — its only import is
// type-only and erased at runtime — and baked into the entries below so the
// landing page reads a single source instead of augmenting at render time.
const landingContentModule = await import(
pathToFileURL(path.join(LANDING_INTEGRATIONS_DATA_PATH, 'landing-content.ts')).href
)
const landingContentMap = (landingContentModule.INTEGRATION_LANDING_CONTENT ?? {}) as Record<
string,
Record<string, unknown>
>
const integrations: IntegrationEntry[] = []
const seenBaseTypes = new Set<string>()
const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort()
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
const switchCaseMap = extractSwitchCaseToolMapping(fileContent)
const configs = extractAllBlockConfigs(fileContent)
for (const config of configs) {
const blockType = config.type
// Canonical integrations filter: only third-party tool blocks visible in the toolbar.
// `isIntegrationBlock` is the single source of truth for "is integration".
if (!isIntegrationBlock(config)) continue
// Every tools-category block MUST declare an `integrationType` from the canonical
// 16-value enum (apps/sim/blocks/types.ts). Fail loudly so the catalog never
// ships a tool without a category bucket.
if (!config.integrationType) {
throw new Error(
`Block "${blockType}" has \`category: 'tools'\` but is missing required \`integrationType\`. ` +
`Add one of the IntegrationType values from apps/sim/blocks/types.ts.`
)
}
if (!INTEGRATION_CATEGORY_VALUES.has(config.integrationType as IntegrationType)) {
throw new Error(
`Block "${blockType}" has unrecognised \`integrationType: "${config.integrationType}"\`. ` +
`Use one of: ${[...INTEGRATION_CATEGORY_VALUES].join(', ')}.`
)
}
const integrationType = config.integrationType as IntegrationType
// Deduplicate by stripped base type
const baseType = stripVersionSuffix(blockType)
if (seenBaseTypes.has(baseType)) continue
seenBaseTypes.add(baseType)
const iconName = (config as any).iconName || iconMapping[blockType] || ''
const rawOps: { label: string; id: string }[] = (config as any).operations || []
// Enrich each operation with a description from the tool registry.
// Lookup order:
// 1. Derive toolId as `{baseType}_{operationId}` and check directly.
// 2. Check switch/case mapping parsed from tools.config.tool (handles
// cases where op IDs differ from tool IDs, e.g. get_carts → list_carts,
// or send_gmail → gmail_send).
// 3. Find the tool in tools.access whose name exactly matches the label.
const toolsAccess: string[] = (config as any).tools?.access || []
const operations: OperationInfo[] = rawOps.map(({ label, id }) => {
const toolId = `${baseType}_${id}`
let opDesc = toolDescMap.get(toolId) || toolDescMap.get(id) || ''
if (!opDesc) {
const switchMappedId = switchCaseMap.get(id)
if (switchMappedId) {
opDesc = toolDescMap.get(switchMappedId) || ''
// Also check versioned variants in tools.access (e.g. gmail_send_v2)
if (!opDesc) {
for (const tId of toolsAccess) {
if (tId === switchMappedId || tId.startsWith(`${switchMappedId}_v`)) {
opDesc = toolDescMap.get(tId) || ''
if (opDesc) break
}
}
}
}
}
if (!opDesc && toolsAccess.length > 0) {
for (const tId of toolsAccess) {
if (toolNameMap.get(tId)?.toLowerCase() === label.toLowerCase()) {
opDesc = toolDescMap.get(tId) || ''
if (opDesc) break
}
}
}
return { name: label, description: opDesc }
})
const triggerIds: string[] = (config as any).triggerIds || []
const triggers: TriggerInfo[] = triggerIds
.map((id) => triggerRegistry.get(id))
.filter((t): t is TriggerInfo => t !== undefined)
const docsUrl = (config as any).docsLink || `https://docs.sim.ai/tools/${baseType}`
const slug = config.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const authType = extractAuthType(fileContent)
integrations.push({
type: blockType,
slug,
name: config.name,
description: config.description,
longDescription: config.longDescription || '',
bgColor: config.bgColor || '#6B7280',
iconName,
docsUrl,
operations,
operationCount: operations.length,
triggers,
triggerCount: triggers.length,
authType,
category: 'tools',
integrationType,
...(config.tags ? { tags: config.tags } : {}),
...(landingContentMap[slug] ? { landingContent: landingContentMap[slug] } : {}),
})
}
}
// Sort alphabetically by name for a predictable, crawl-friendly order
integrations.sort((a, b) => a.name.localeCompare(b.name))
const jsonPath = path.join(INTEGRATIONS_DATA_PATH, 'integrations.json')
// `JSON.stringify` always expands every array across multiple lines, but Biome's
// JSON formatter inlines short arrays of primitive strings. Pre-collapse those
// arrays here so the emitted file is already in Biome's canonical shape and
// `bun run check` does not churn it on every commit.
const json = JSON.stringify(integrations, null, 2).replace(
/\[\n(\s+"[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s+\]/g,
(_match, inner) => {
const items = (inner as string).split(',\n').map((s: string) => s.trim())
return `[${items.join(', ')}]`
}
)
fs.writeFileSync(jsonPath, `${json}\n`)
console.log(`✓ Integration data written: ${integrations.length} integrations → ${jsonPath}`)
} catch (error) {
// Surface taxonomy violations (missing/invalid `integrationType`) loudly —
// they are programmer errors that must fail the generator, not be logged
// and silently swallowed.
console.error('Error writing integrations JSON:', error)
throw error
}
}
/**
* Extract ALL block configs from a file, filtering out hidden blocks
*/
function extractAllBlockConfigs(fileContent: string): BlockConfig[] {
const configs: BlockConfig[] = []
// First, extract the primary icon from the file (for V2 blocks that inherit via spread)
const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports in the file
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
let match
while ((match = exportRegex.exec(fileContent)) !== null) {
const blockName = match[1]
const startIndex = match.index + match[0].length - 1 // Position of opening brace
// Extract the block content by matching braces
const endIndex = findMatchingClose(fileContent, startIndex)
if (endIndex !== -1) {
const blockContent = fileContent.substring(startIndex, endIndex)
// Check if this block has hideFromToolbar: true
const hideFromToolbar = /hideFromToolbar\s*:\s*true/.test(blockContent)
if (hideFromToolbar) {
console.log(`Skipping ${blockName}Block - hideFromToolbar is true`)
continue
}
// Pass fileContent to enable spread inheritance resolution
const config = extractBlockConfigFromContent(blockContent, blockName, fileContent)
if (config) {
// For V2 blocks that don't have an explicit icon, use the primary icon from the file
if (!config.iconName && primaryIcon) {
;(config as any).iconName = primaryIcon
}
configs.push(config)
}
}
}
return configs
}
/**
* Extract the name of the spread base block (e.g., "GitHubBlock" from "...GitHubBlock")
*/
function extractSpreadBase(blockContent: string): string | null {
const spreadMatch = blockContent.match(/^\s*\.\.\.(\w+Block)\s*,/m)
return spreadMatch ? spreadMatch[1] : null
}
/**
* Extract block config from a specific block's content
* If the block uses spread inheritance (e.g., ...GitHubBlock), attempts to resolve
* missing properties from the base block in the file content.
*/
function extractBlockConfigFromContent(
blockContent: string,
blockName: string,
fileContent?: string
): BlockConfig | null {
try {
// Check for spread inheritance
const spreadBase = extractSpreadBase(blockContent)
let baseConfig: BlockConfig | null = null
if (spreadBase && fileContent) {
// Extract the base block's content from the file
const baseBlockRegex = new RegExp(
`export\\s+const\\s+${spreadBase}\\s*:\\s*BlockConfig[^=]*=\\s*\\{`,
'g'
)
const baseMatch = baseBlockRegex.exec(fileContent)
if (baseMatch) {
const startIndex = baseMatch.index + baseMatch[0].length - 1
const endIndex = findMatchingClose(fileContent, startIndex)
if (endIndex !== -1) {
const baseBlockContent = fileContent.substring(startIndex, endIndex)
// Recursively extract base config (but don't pass fileContent to avoid infinite loops)
baseConfig = extractBlockConfigFromContent(
baseBlockContent,
spreadBase.replace('Block', '')
)
}
}
}
// Extract properties from this block, using topLevelOnly=true for main properties
const blockType =
extractStringPropertyFromContent(blockContent, 'type', true) || blockName.toLowerCase()
const name =
extractStringPropertyFromContent(blockContent, 'name', true) ||
baseConfig?.name ||
`${blockName} Block`
const description =
extractStringPropertyFromContent(blockContent, 'description', true) ||
baseConfig?.description ||
''
const longDescription =
extractStringPropertyFromContent(blockContent, 'longDescription', true) ||
baseConfig?.longDescription ||
''
const category =
extractStringPropertyFromContent(blockContent, 'category', true) || baseConfig?.category || ''
const bgColor =
extractStringPropertyFromContent(blockContent, 'bgColor', true) ||
baseConfig?.bgColor ||
'#F5F5F5'
const iconName = extractIconNameFromContent(blockContent) || (baseConfig as any)?.iconName || ''
const outputs = extractOutputsFromContent(blockContent)
const toolsAccess = extractToolsAccessFromContent(blockContent)
// For tools.access, if not found directly, check if it's derived from base via map
let finalToolsAccess = toolsAccess
if (toolsAccess.length === 0 && baseConfig?.tools?.access) {
// Check if there's a map operation on base tools
// Pattern: access: (SomeBlock.tools?.access || []).map((toolId) => `${toolId}_v2`)
const mapMatch = blockContent.match(
/access\s*:\s*\(\s*\w+Block\.tools\?\.access\s*\|\|\s*\[\]\s*\)\.map\s*\(\s*\(\s*\w+\s*\)\s*=>\s*`\$\{\s*\w+\s*\}_v(\d+)`\s*\)/
)
if (mapMatch) {
// V2 block - append the version suffix to base tools
const versionSuffix = `_v${mapMatch[1]}`
finalToolsAccess = baseConfig.tools.access.map((tool) => `${tool}${versionSuffix}`)
}
}
const operations = extractOperationsFromContent(blockContent)
const triggerIds = extractTriggersAvailable(blockContent)
const docsLink =
extractStringPropertyFromContent(blockContent, 'docsLink', true) ||
baseConfig?.docsLink ||
`https://docs.sim.ai/tools/${stripVersionSuffix(blockType)}`
const integrationType =
extractEnumPropertyFromContent(blockContent, 'integrationType') ||
baseConfig?.integrationType ||
null
// Tags live on the block's `<BlockName>BlockMeta` export. For spread-inheriting
// blocks (e.g. `ConfluenceV2Block` extending `ConfluenceBlock`), also try the
// spread base's meta so V2 variants inherit tags.
const tags =
(fileContent ? extractTagsFromBlockMeta(fileContent, blockName) : null) ||
(fileContent && spreadBase
? extractTagsFromBlockMeta(fileContent, spreadBase.replace(/Block$/, ''))
: null)
return {
type: blockType,
name,
description,
longDescription,
category,
bgColor,
iconName,
outputs,
tools: {
access: finalToolsAccess.length > 0 ? finalToolsAccess : baseConfig?.tools?.access || [],
},
operations: operations.length > 0 ? operations : (baseConfig as any)?.operations || [],
triggerIds: triggerIds.length > 0 ? triggerIds : (baseConfig as any)?.triggerIds || [],
docsLink,
...(integrationType ? { integrationType } : {}),
...(tags ? { tags } : {}),
}
} catch (error) {
console.error(`Error extracting block configuration for ${blockName}:`, error)
return null
}
}
/**
* The single predicate that decides whether an extracted block config belongs
* in the integration surfaces emitted by this script — the integrations
* catalog (`integrations.json`) and the per-tool `/tools/*.mdx` docs. A block
* qualifies only when it is a third-party integration (`category: 'tools'`)
* that is currently surfaced in the toolbar (`hideFromToolbar` not set). Under
* the versioning upgrade paradigm only the latest version is visible, so this
* also naturally selects the canonical version. Recategorizing a block to
* `'blocks'` or `'triggers'` removes it from all integration surfaces.
*/
function isIntegrationBlock(config: { category?: string; hideFromToolbar?: boolean }): boolean {
return config.category === 'tools' && !config.hideFromToolbar
}
/**
* First-party `category: 'blocks'` primitives that are nonetheless surfaced on
* the integrations page (and therefore need an icon-mapping entry) even though
* they are not third-party integrations. Everything else in the `'blocks'`
* category — including first-party AI capabilities like the image/video
* generators, vision, and STT/TTS — is excluded from the integrations icon
* map, matching {@link isIntegrationBlock}.
*/
const ICON_MAP_BLOCK_CATEGORY_ALLOWLIST = new Set(['memory', 'knowledge'])
/**
* Block types that never belong in the integrations icon map regardless of
* category — core primitives, triggers, and webhook/feed plumbing.
*/
const ICON_MAP_EXCLUDED_TYPES = new Set([
'evaluator',
'number',
'webhook',
'schedule',
'mcp',
'generic_webhook',
'rss',
])
/**
* Extract a string property from block content.
* For top-level properties like 'description', only looks in the portion before nested objects
* to avoid matching properties inside nested structures like outputs.
*/
function extractStringPropertyFromContent(
content: string,
propName: string,
topLevelOnly = false
): string | null {
let searchContent = content
// For top-level properties, only search before nested objects like outputs, tools, inputs, subBlocks
if (topLevelOnly) {
const nestedObjectPatterns = [
/\boutputs\s*:\s*\{/,
/\btools\s*:\s*\{/,
/\binputs\s*:\s*\{/,
/\bsubBlocks\s*:\s*\[/,
/\btriggers\s*:\s*\{/,
]
let cutoffIndex = content.length
for (const pattern of nestedObjectPatterns) {
const match = content.match(pattern)
if (match && match.index !== undefined && match.index < cutoffIndex) {
cutoffIndex = match.index
}
}
searchContent = content.substring(0, cutoffIndex)
}
const singleQuoteMatch = searchContent.match(new RegExp(`${propName}\\s*:\\s*'([^']*)'`, 'm'))
if (singleQuoteMatch) return singleQuoteMatch[1]
const doubleQuoteMatch = searchContent.match(new RegExp(`${propName}\\s*:\\s*"([^"]*)"`, 'm'))
if (doubleQuoteMatch) return doubleQuoteMatch[1]
const templateMatch = searchContent.match(new RegExp(`${propName}\\s*:\\s*\`([^\`]+)\``, 's'))
if (templateMatch) {
let templateContent = templateMatch[1]
templateContent = templateContent.replace(/\$\{[^}]+\}/g, '')
templateContent = templateContent.replace(/\s+/g, ' ').trim()
return templateContent
}
return null
}
/**
* Extract an enum property value from block content. Maps an `IntegrationType`
* enum key (e.g. `Communication`) to its slug value (e.g. `'communication'`).
* Mirrors `apps/sim/blocks/types.ts → IntegrationType` — keep in sync.
*/
function extractEnumPropertyFromContent(content: string, propName: string): string | null {
const match = content.match(new RegExp(`${propName}\\s*:\\s*IntegrationType\\.(\\w+)`))
if (!match) return null
const enumKey = match[1]
const ENUM_MAP: Record<string, string> = {
AI: 'ai',
Analytics: 'analytics',
Commerce: 'commerce',
Communication: 'communication',
Databases: 'databases',
DevOps: 'devops',
Documents: 'documents',
Email: 'email',
HR: 'hr',
Marketing: 'marketing',
Observability: 'observability',
Productivity: 'productivity',
Sales: 'sales',
Search: 'search',
Security: 'security',
Support: 'support',
}
return ENUM_MAP[enumKey] || enumKey.toLowerCase()
}
/**
* Extract a string array property from block content.
* Matches patterns like `tags: ['api', 'oauth', 'webhooks']`
*/
function extractArrayPropertyFromContent(content: string, propName: string): string[] | null {
const match = content.match(new RegExp(`${propName}\\s*:\\s*\\[([^\\]]+)\\]`))
if (!match) return null
const items = match[1].match(/'([^']+)'|"([^"]+)"/g)
if (!items) return null
return items.map((item) => item.replace(/['"]/g, ''))
}
/**
* Extract `tags` from a `<BlockName>BlockMeta` literal in the source file.
* Looks for `export const <BlockName>BlockMeta = { ... tags: [...] ... }`
* at file scope and scans only the body of that literal. Returns null when
* no matching meta export exists or it contains no `tags` array.
*
* During the in-progress migration to per-block meta, some blocks declare
* `tags` on `BlockConfig` and others on `*BlockMeta`. The caller should
* try this extractor first and fall back to the `BlockConfig` extractor.
*/
function extractTagsFromBlockMeta(fileContent: string, blockName: string): string[] | null {
const headerRegex = new RegExp(`export\\s+const\\s+${blockName}BlockMeta\\s*(?::[^=]+)?=\\s*\\{`)
const metaHeaderMatch = fileContent.match(headerRegex)
if (!metaHeaderMatch || metaHeaderMatch.index === undefined) return null
const openBracePos = fileContent.indexOf('{', metaHeaderMatch.index)
if (openBracePos === -1) return null
const closeBracePos = findMatchingClose(fileContent, openBracePos)
if (closeBracePos === -1) return null
const metaBody = fileContent.substring(openBracePos + 1, closeBracePos)
return extractArrayPropertyFromContent(metaBody, 'tags')
}
function extractIconNameFromContent(content: string): string | null {
const iconMatch = content.match(/icon\s*:\s*(\w+Icon)/)
return iconMatch ? iconMatch[1] : null
}
function extractOutputsFromContent(content: string): Record<string, any> {
const outputsStart = content.search(/outputs\s*:\s*{/)
if (outputsStart === -1) return {}
const openBracePos = content.indexOf('{', outputsStart)
if (openBracePos === -1) return {}
const pos = findMatchingClose(content, openBracePos)
if (pos === -1) return {}
const outputsContent = content.substring(openBracePos + 1, pos - 1).trim()
const outputs: Record<string, any> = {}
const fieldRegex = /(\w+)\s*:\s*{/g
let match
const fieldPositions: Array<{ name: string; start: number }> = []
while ((match = fieldRegex.exec(outputsContent)) !== null) {
fieldPositions.push({
name: match[1],
start: match.index + match[0].length - 1,
})
}
fieldPositions.forEach((field) => {
const endPos = findMatchingClose(outputsContent, field.start)
if (endPos !== -1) {
const fieldContent = outputsContent.substring(field.start + 1, endPos - 1).trim()
const typeMatch = fieldContent.match(/type\s*:\s*['"](.*?)['"]/)
const description = extractDescription(fieldContent)
if (typeMatch) {
outputs[field.name] = {
type: typeMatch[1],
description: description || `${field.name} output from the block`,
}
}
}
})
return outputs
}
function extractToolsAccessFromContent(content: string): string[] {
const accessMatch = content.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/)
if (!accessMatch) return []
return [...accessMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
}
/**
* Get the tool prefix (service name) from a tool name.
* e.g., "calcom_list_schedules" -> "calcom"
*/
function getToolPrefixFromName(toolName: string): string {
const parts = toolName.split('_')
// Try to find a valid tool directory
for (let i = parts.length - 1; i >= 1; i--) {
const possiblePrefix = parts.slice(0, i).join('_')
const toolDirPath = path.join(rootDir, `apps/sim/tools/${possiblePrefix}`)
if (fs.existsSync(toolDirPath) && fs.statSync(toolDirPath).isDirectory()) {
return possiblePrefix
}
}
return parts[0]
}
/**
* Resolve a const reference from a types file.
* Handles nested const references recursively.
*
* @param constName - The const name to resolve (e.g., "SCHEDULE_DATA_OUTPUT_PROPERTIES")
* @param toolPrefix - The tool prefix/service name (e.g., "calcom")
* @param depth - Recursion depth to prevent infinite loops
* @returns Resolved properties object or null if not found
*/
function resolveConstReference(
constName: string,
toolPrefix: string,
depth = 0
): Record<string, any> | null {
// Prevent infinite recursion
if (depth > 10) {
console.warn(`Max recursion depth reached resolving const: ${constName}`)
return null
}
// Check cache first
const cacheKey = `${toolPrefix}:${constName}`
if (constResolutionCache.has(cacheKey)) {
return constResolutionCache.get(cacheKey)!
}
// Read the types file for this tool
const typesFilePath = path.join(rootDir, `apps/sim/tools/${toolPrefix}/types.ts`)
if (!fs.existsSync(typesFilePath)) {
// Try to find const in the tool file itself
return null
}
const typesContent = fs.readFileSync(typesFilePath, 'utf-8')
// Find the const definition
// Pattern: export const CONST_NAME = { ... } as const
const constRegex = new RegExp(
`export\\s+const\\s+${constName}\\s*(?::\\s*[^=]+)?\\s*=\\s*\\{`,
'g'
)
const constMatch = constRegex.exec(typesContent)
if (!constMatch) {
return null
}
// Extract the const content
const startIndex = constMatch.index + constMatch[0].length - 1
const endIndex = findMatchingClose(typesContent, startIndex)
if (endIndex === -1) {
return null
}
const constContent = typesContent.substring(startIndex + 1, endIndex - 1).trim()
// Check if this const defines a complete output field (has type property)
// like EVENT_TYPE_OUTPUT = { type: 'object', description: '...', properties: {...} }
const typeMatch = constContent.match(/^\s*type\s*:\s*['"]([^'"]+)['"]/)
if (typeMatch) {
// This is a complete output definition - use parseConstFieldContent
const result = parseConstFieldContent(constContent, toolPrefix, typesContent, depth + 1)
if (result) {
constResolutionCache.set(cacheKey, result)
}
return result
}
// Otherwise, this is a properties object - use parseConstProperties
const properties = parseConstProperties(constContent, toolPrefix, typesContent, depth + 1)
// Cache the result
constResolutionCache.set(cacheKey, properties)
return properties
}
/**
* Parse properties from a const definition, resolving nested const references.
*/
function parseConstProperties(
content: string,
toolPrefix: string,
typesContent: string,
depth: number
): Record<string, any> {
const properties: Record<string, any> = {}
// First, handle spread operators (e.g., "...COMMENT_OUTPUT_PROPERTIES,")
const spreadRegex = /\.\.\.([A-Z][A-Z_0-9]+)\s*(?:,|$)/g
let spreadMatch
while ((spreadMatch = spreadRegex.exec(content)) !== null) {
const constName = spreadMatch[1]
// Check if at depth 0
const beforeMatch = content.substring(0, spreadMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstFromTypesContent(constName, typesContent, toolPrefix, depth)
if (resolvedConst && typeof resolvedConst === 'object') {
// Spread all properties from the resolved const
Object.assign(properties, resolvedConst)
}
}
// Find all top-level property definitions
const propRegex = /(\w+)\s*:\s*(?:\{|([A-Z][A-Z_0-9]+)(?:\s*,|\s*$))/g
let match
while ((match = propRegex.exec(content)) !== null) {
const propName = match[1]
const constRef = match[2]
// Skip 'items' keyword (always a nested structure, never a field name)
if (propName === 'items') {
continue
}
// Check if this match is at depth 0 (not inside nested braces)
const beforeMatch = content.substring(0, match.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue // Skip - this is a nested property
}
// For 'properties' or 'type', check if it's an output field definition vs a keyword
// Output field definitions have 'type:' inside (e.g., { type: 'string', description: '...' })
if ((propName === 'properties' || propName === 'type') && !constRef) {
// Peek at what's inside the braces
const startPos = match.index + match[0].length - 1
const endPos = findMatchingClose(content, startPos)
if (endPos !== -1) {
const propContent = content.substring(startPos + 1, endPos - 1).trim()
// If it starts with 'type:', it's an output field definition - process it
if (propContent.match(/^\s*type\s*:/)) {
const parsedProp = parseConstFieldContent(propContent, toolPrefix, typesContent, depth)
if (parsedProp) {
properties[propName] = parsedProp
}
}
// Otherwise, it's a keyword usage (nested properties block or type specifier) - skip it
}
continue
}
if (constRef) {
// This property references a const (e.g., "attendees: ATTENDEES_OUTPUT")
const resolvedConst = resolveConstFromTypesContent(constRef, typesContent, toolPrefix, depth)
if (resolvedConst) {
properties[propName] = resolvedConst
}
} else {
// This property has inline definition
const startPos = match.index + match[0].length - 1
const endPos = findMatchingClose(content, startPos)
if (endPos !== -1) {
const propContent = content.substring(startPos + 1, endPos - 1).trim()
const parsedProp = parseConstFieldContent(propContent, toolPrefix, typesContent, depth)
if (parsedProp) {
properties[propName] = parsedProp
}
}
}
}
return properties
}
/**
* Resolve a const from the types content (for nested references within the same file).
*/
function resolveConstFromTypesContent(
constName: string,
typesContent: string,
toolPrefix: string,
depth: number
): Record<string, any> | null {
if (depth > 10) return null
// Check cache
const cacheKey = `${toolPrefix}:${constName}`
if (constResolutionCache.has(cacheKey)) {
return constResolutionCache.get(cacheKey)!
}
// Find the const definition in typesContent
const constRegex = new RegExp(
`export\\s+const\\s+${constName}\\s*(?::\\s*[^=]+)?\\s*=\\s*\\{`,
'g'
)
const constMatch = constRegex.exec(typesContent)
if (!constMatch) {
return null
}
const startIndex = constMatch.index + constMatch[0].length - 1
const endIndex = findMatchingClose(typesContent, startIndex)
if (endIndex === -1) return null
const constContent = typesContent.substring(startIndex + 1, endIndex - 1).trim()
// Check if this const defines a complete output field (has type property)
const typeMatch = constContent.match(/^\s*type\s*:\s*['"]([^'"]+)['"]/)
if (typeMatch) {
// This is a complete output definition (like ATTENDEES_OUTPUT)
const result = parseConstFieldContent(constContent, toolPrefix, typesContent, depth)
if (result) {
constResolutionCache.set(cacheKey, result)
}
return result
}
// This is a properties object (like ATTENDEE_OUTPUT_PROPERTIES)
const properties = parseConstProperties(constContent, toolPrefix, typesContent, depth + 1)
constResolutionCache.set(cacheKey, properties)
return properties
}
/**
* Parse a field content from a const, resolving nested const references.
*/
/**
* Extract description from field content, handling quoted strings properly.
* Handles single quotes, double quotes, and backticks, preserving internal quotes.
*/
function extractDescription(fieldContent: string): string | null {
// Walk through all `description:` matches and return the first one at depth 0.
// This prevents accidentally picking up `description:` keys inside nested child objects.
const descRegex = /description\s*:\s*('([^']*)'|"([^"]*)"|`([^`]*)`)/g
let m: RegExpExecArray | null
while ((m = descRegex.exec(fieldContent)) !== null) {
if (isAtDepthZero(fieldContent, m.index)) {
return m[2] ?? m[3] ?? m[4] ?? null
}
}
return null
}
function parseConstFieldContent(
fieldContent: string,
toolPrefix: string,
typesContent: string,
depth: number
): any {
const typeMatch = fieldContent.match(/type\s*:\s*['"]([^'"]+)['"]/)
const description = extractDescription(fieldContent)
if (!typeMatch) return null
const fieldType = typeMatch[1]
const result: any = {
type: fieldType,
description: description || '',
}
// Check for properties - either inline or const reference
if (fieldType === 'object' || fieldType === 'json') {
// Check for const reference first
const propsConstMatch = fieldContent.match(/properties\s*:\s*([A-Z][A-Z_0-9]+)/)
if (propsConstMatch) {
const resolvedProps = resolveConstFromTypesContent(
propsConstMatch[1],
typesContent,
toolPrefix,
depth + 1
)
if (resolvedProps) {
result.properties = resolvedProps
}
} else {
// Check for inline properties
const propertiesStart = fieldContent.search(/properties\s*:\s*\{/)
if (propertiesStart !== -1) {
const braceStart = fieldContent.indexOf('{', propertiesStart)
const braceEnd = findMatchingClose(fieldContent, braceStart)
if (braceEnd !== -1) {
const propertiesContent = fieldContent.substring(braceStart + 1, braceEnd - 1).trim()
result.properties = parseConstProperties(
propertiesContent,
toolPrefix,
typesContent,
depth + 1
)
}
}
}
}
// Check for items (arrays)
const itemsConstMatch = fieldContent.match(/items\s*:\s*([A-Z][A-Z_0-9]+)/)
if (itemsConstMatch) {
const resolvedItems = resolveConstFromTypesContent(
itemsConstMatch[1],
typesContent,
toolPrefix,
depth + 1
)
if (resolvedItems) {
result.items = resolvedItems
}
} else {
const itemsStart = fieldContent.search(/items\s*:\s*\{/)
if (itemsStart !== -1) {
const braceStart = fieldContent.indexOf('{', itemsStart)
const braceEnd = findMatchingClose(fieldContent, braceStart)
if (braceEnd !== -1) {
const itemsContent = fieldContent.substring(braceStart + 1, braceEnd - 1).trim()
const itemsType = itemsContent.match(/type\s*:\s*['"]([^'"]+)['"]/)
const itemsDesc = extractDescription(itemsContent)
result.items = {
type: itemsType ? itemsType[1] : 'object',
description: itemsDesc || '',
}
// Check for properties in items - either inline or const reference
const itemsPropsConstMatch = itemsContent.match(/properties\s*:\s*([A-Z][A-Z_0-9]+)/)
if (itemsPropsConstMatch) {
const resolvedProps = resolveConstFromTypesContent(
itemsPropsConstMatch[1],
typesContent,
toolPrefix,
depth + 1
)
if (resolvedProps) {
result.items.properties = resolvedProps
}
} else {
const itemsPropsStart = itemsContent.search(/properties\s*:\s*\{/)
if (itemsPropsStart !== -1) {
const propsBraceStart = itemsContent.indexOf('{', itemsPropsStart)
let propsBraceCount = 1
let propsBraceEnd = propsBraceStart + 1
while (propsBraceEnd < itemsContent.length && propsBraceCount > 0) {
if (itemsContent[propsBraceEnd] === '{') propsBraceCount++
else if (itemsContent[propsBraceEnd] === '}') propsBraceCount--
propsBraceEnd++
}
if (propsBraceCount === 0) {
const itemsPropsContent = itemsContent
.substring(propsBraceStart + 1, propsBraceEnd - 1)
.trim()
result.items.properties = parseConstProperties(
itemsPropsContent,
toolPrefix,
typesContent,
depth + 1
)
}
}
}
}
}
}
return result
}
/**
* Extract outputs from a tool content block by trying:
* 1. Const reference (e.g., `outputs: GIT_REF_OUTPUT_PROPERTIES,`)
* 2. Inline object (e.g., `outputs: { id: { type: 'string', ... } }`)
*/
function extractOutputsFromToolContent(content: string, toolPrefix: string): Record<string, any> {
const constMatch = content.match(/(?<![a-zA-Z_])outputs\s*:\s*([A-Z][A-Z_0-9]+)\s*(?:,|\}|$)/)
if (constMatch) {
const resolved = resolveConstReference(constMatch[1], toolPrefix)
if (resolved && typeof resolved === 'object') {
return resolved
}
}
const outputsStart = content.search(/(?<![a-zA-Z_])outputs\s*:\s*{/)
if (outputsStart !== -1) {
const openBracePos = content.indexOf('{', outputsStart)
if (openBracePos !== -1) {
const closePos = findMatchingClose(content, openBracePos)
if (closePos !== -1) {
const outputsContent = content.substring(openBracePos + 1, closePos - 1).trim()
return parseToolOutputsField(outputsContent, toolPrefix)
}
}
}
return {}
}
function extractToolInfo(
toolName: string,
fileContent: string
): {
description: string
params: Array<{ name: string; type: string; required: boolean; description: string }>
outputs: Record<string, any>
} | null {
try {
// First, try to find the specific tool definition by its ID
// Look for: id: 'toolName' or id: "toolName"
const toolIdRegex = new RegExp(`id:\\s*['"]${toolName}['"]`)
const toolIdMatch = fileContent.match(toolIdRegex)
let toolContent = fileContent
if (toolIdMatch && toolIdMatch.index !== undefined) {
// Find the tool definition block that contains this ID
// Search backwards for 'export const' or start of object
const beforeId = fileContent.substring(0, toolIdMatch.index)
const exportMatch = beforeId.match(/export\s+const\s+\w+[^=]*=\s*\{[\s\S]*$/)
if (exportMatch && exportMatch.index !== undefined) {
const startIndex = exportMatch.index + exportMatch[0].length - 1
const endIndex = findMatchingClose(fileContent, startIndex)
if (endIndex !== -1) {
toolContent = fileContent.substring(startIndex, endIndex)
}
}
}
// Params are often inherited via spread, so search the full file for params
const toolConfigRegex =
/params\s*:\s*{([\s\S]*?)},?\s*(?:outputs|oauth|request|directExecution|postProcess|transformResponse)\s*:/
const toolConfigMatch = fileContent.match(toolConfigRegex)
// Description should come from the specific tool block if found
// Only search before nested objects (params, outputs, request, etc.) to avoid matching
// descriptions inside outputs or params
let descriptionSearchContent = toolContent
const nestedObjectPatterns = [
/\bparams\s*:\s*[{]/,
/\boutputs\s*:\s*\{/,
/\brequest\s*:\s*\{/,
/\boauth\s*:\s*\{/,
/\btransformResponse\s*:/,
]
let cutoffIndex = toolContent.length
for (const pattern of nestedObjectPatterns) {
const match = toolContent.match(pattern)
if (match && match.index !== undefined && match.index < cutoffIndex) {
cutoffIndex = match.index
}
}
descriptionSearchContent = toolContent.substring(0, cutoffIndex)
const descriptionRegex = /description\s*:\s*['"](.*?)['"].*/
let descriptionMatch = descriptionSearchContent.match(descriptionRegex)
// If description isn't found as a literal (might be inherited like description: baseTool.description),
// try to find the referenced tool's description
if (!descriptionMatch) {
const inheritedDescMatch = descriptionSearchContent.match(
/description\s*:\s*(\w+)Tool\.description/
)
if (inheritedDescMatch) {
const baseTool = inheritedDescMatch[1]
// Try to find the base tool's description in the file
const baseToolDescRegex = new RegExp(
`export\\s+const\\s+${baseTool}Tool[^{]*\\{[\\s\\S]*?description\\s*:\\s*['"]([^'"]+)['"]`,
'i'
)
const baseToolMatch = fileContent.match(baseToolDescRegex)
if (baseToolMatch) {
descriptionMatch = baseToolMatch
}
}
}
const description = descriptionMatch ? descriptionMatch[1] : 'No description available'
const params: Array<{ name: string; type: string; required: boolean; description: string }> = []
if (toolConfigMatch) {
const paramsContent = toolConfigMatch[1]
const paramBlocksRegex = /(\w+)\s*:\s*{/g
let paramMatch
const paramPositions: Array<{ name: string; start: number; content: string }> = []
/**
* Checks if a position in the string is inside a quoted string.
* This prevents matching patterns like "Example: {" inside description strings.
*/
const isInsideString = (content: string, position: number): boolean => {
let inSingleQuote = false
let inDoubleQuote = false
let inBacktick = false
for (let i = 0; i < position; i++) {
const char = content[i]
const prevChar = i > 0 ? content[i - 1] : ''
// Skip escaped quotes
if (prevChar === '\\') continue
if (char === "'" && !inDoubleQuote && !inBacktick) {
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote && !inBacktick) {
inDoubleQuote = !inDoubleQuote
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
inBacktick = !inBacktick
}
}
return inSingleQuote || inDoubleQuote || inBacktick
}
while ((paramMatch = paramBlocksRegex.exec(paramsContent)) !== null) {
const paramName = paramMatch[1]
const startPos = paramMatch.index + paramMatch[0].length - 1
// Skip matches that are inside string literals (e.g., "Example: {" in descriptions)
if (isInsideString(paramsContent, paramMatch.index)) {
continue
}
const endPos = findMatchingClose(paramsContent, startPos)
if (endPos !== -1) {
const paramBlock = paramsContent.substring(startPos + 1, endPos - 1).trim()
paramPositions.push({ name: paramName, start: startPos, content: paramBlock })
}
}
for (const param of paramPositions) {
const paramName = param.name
const paramBlock = param.content
if (paramName === 'accessToken' || paramName === 'params' || paramName === 'tools') {
continue
}
const typeMatch = paramBlock.match(/type\s*:\s*['"]([^'"]+)['"]/)
const requiredMatch = paramBlock.match(/required\s*:\s*(true|false)/)
let descriptionMatch = paramBlock.match(/description\s*:\s*'(.*?)'(?=\s*[,}])/s)
if (!descriptionMatch) {
descriptionMatch = paramBlock.match(/description\s*:\s*"(.*?)"(?=\s*[,}])/s)
}
if (!descriptionMatch) {
descriptionMatch = paramBlock.match(/description\s*:\s*`([^`]+)`/s)
}
if (!descriptionMatch) {
descriptionMatch = paramBlock.match(
/description\s*:\s*['"]([^'"]*(?:\n[^'"]*)*?)['"](?=\s*[,}])/s
)
}
params.push({
name: paramName,
type: typeMatch ? typeMatch[1] : 'string',
required: requiredMatch ? requiredMatch[1] === 'true' : false,
description: descriptionMatch ? descriptionMatch[1] : 'No description',
})
}
}
// Get the tool prefix for resolving const references
const toolPrefix = getToolPrefixFromName(toolName)
let outputs = extractOutputsFromToolContent(toolContent, toolPrefix)
// If no outputs found, check for spread inheritance (e.g., "...extendParserTool")
// toolContent may be narrowed past the spread line, so reconstruct the full block
if (Object.keys(outputs).length === 0) {
let fullToolBlock = toolContent
if (toolIdMatch && toolIdMatch.index !== undefined) {
const beforeId = fileContent.substring(0, toolIdMatch.index)
const exportRegex = /export\s+const\s+\w+[^=]*=\s*\{/g
let lastExportMatch: RegExpExecArray | null = null
let m: RegExpExecArray | null = null
while ((m = exportRegex.exec(beforeId)) !== null) {
lastExportMatch = m
}
if (lastExportMatch && lastExportMatch.index !== undefined) {
const bracePos = lastExportMatch.index + lastExportMatch[0].length - 1
const ep = findMatchingClose(fileContent, bracePos)
if (ep !== -1) {
fullToolBlock = fileContent.substring(bracePos, ep)
}
}
}
const spreadMatch = fullToolBlock.match(/\.\.\.(\w+(?:Tool|Base)\w*)/)
if (spreadMatch) {
const baseVarName = spreadMatch[1]
const baseToolRegex = new RegExp(
`export\\s+const\\s+${baseVarName}(?=[^a-zA-Z0-9_]|$)[^=]*=\\s*\\{`
)
const baseToolMatch = fileContent.match(baseToolRegex)
if (baseToolMatch && baseToolMatch.index !== undefined) {
const baseStart = baseToolMatch.index + baseToolMatch[0].length - 1
const endIdx = findMatchingClose(fileContent, baseStart)
if (endIdx !== -1) {
const baseToolContent = fileContent.substring(baseStart, endIdx)
outputs = extractOutputsFromToolContent(baseToolContent, toolPrefix)
}
}
}
}
return {
description,
params,
outputs,
}
} catch (error) {
console.error(`Error extracting info for tool ${toolName}:`, error)
return null
}
}
function formatOutputStructure(outputs: Record<string, any>, indentLevel = 0): string {
let result = ''
for (const [key, output] of Object.entries(outputs)) {
let type = 'unknown'
let description = `${key} output from the tool`
if (typeof output === 'object' && output !== null) {
if (output.type) {
type = output.type
}
if (output.description) {
description = output.description
}
}
const escapedDescription = description
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Build prefix based on indent level - each level adds 2 spaces before the arrow
let prefix = ''
if (indentLevel > 0) {
const spaces = ' '.repeat(indentLevel)
prefix = `${spaces}↳ `
}
if (typeof output === 'object' && output !== null && output.type === 'array') {
result += `| ${prefix}\`${key}\` | ${type} | ${escapedDescription} |\n`
if (output.items?.properties) {
const arrayItemsResult = formatOutputStructure(output.items.properties, indentLevel + 1)
result += arrayItemsResult
}
} else if (
typeof output === 'object' &&
output !== null &&
output.properties &&
(output.type === 'object' || output.type === 'json')
) {
result += `| ${prefix}\`${key}\` | ${type} | ${escapedDescription} |\n`
const nestedResult = formatOutputStructure(output.properties, indentLevel + 1)
result += nestedResult
} else {
result += `| ${prefix}\`${key}\` | ${type} | ${escapedDescription} |\n`
}
}
return result
}
function parseToolOutputsField(outputsContent: string, toolPrefix?: string): Record<string, any> {
const outputs: Record<string, any> = {}
// First, handle top-level const references
// Patterns: "data: BOOKING_DATA_OUTPUT_PROPERTIES" or "pagination: PAGINATION_OUTPUT"
if (toolPrefix) {
// Pattern 1: Direct const reference
const constRefRegex = /(\w+)\s*:\s*([A-Z][A-Z_0-9]+)\s*(?:,|$)/g
let constMatch
while ((constMatch = constRefRegex.exec(outputsContent)) !== null) {
const propName = constMatch[1]
const constName = constMatch[2]
// Check if at depth 0
const beforeMatch = outputsContent.substring(0, constMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst) {
outputs[propName] = resolvedConst
}
}
// Pattern 2: Property access on const (e.g., "status: BOOKING_DATA_OUTPUT_PROPERTIES.status,")
const propAccessRegex = /(\w+)\s*:\s*([A-Z][A-Z_0-9]+)\.(\w+)\s*(?:,|$)/g
let propAccessMatch
while ((propAccessMatch = propAccessRegex.exec(outputsContent)) !== null) {
const propName = propAccessMatch[1]
const constName = propAccessMatch[2]
const accessedProp = propAccessMatch[3]
// Skip if already resolved
if (outputs[propName]) {
continue
}
// Check if at depth 0
const beforeMatch = outputsContent.substring(0, propAccessMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst?.[accessedProp]) {
outputs[propName] = resolvedConst[accessedProp]
}
}
// Pattern 3: Spread operator (e.g., "...COMMENT_OUTPUT_PROPERTIES,")
const spreadRegex = /\.\.\.([A-Z][A-Z_0-9]+)\s*(?:,|$)/g
let spreadMatch
while ((spreadMatch = spreadRegex.exec(outputsContent)) !== null) {
const constName = spreadMatch[1]
// Check if at depth 0 (not inside nested braces)
const beforeMatch = outputsContent.substring(0, spreadMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst && typeof resolvedConst === 'object') {
// Spread all properties from the resolved const
Object.assign(outputs, resolvedConst)
}
}
}
const braces: Array<{ type: 'open' | 'close'; pos: number; level: number }> = []
for (let i = 0; i < outputsContent.length; i++) {
if (outputsContent[i] === '{') {
braces.push({ type: 'open', pos: i, level: 0 })
} else if (outputsContent[i] === '}') {
braces.push({ type: 'close', pos: i, level: 0 })
}
}
let currentLevel = 0
for (const brace of braces) {
if (brace.type === 'open') {
brace.level = currentLevel
currentLevel++
} else {
currentLevel--
brace.level = currentLevel
}
}
const fieldStartRegex = /(\w+)\s*:\s*{/g
let match
const fieldPositions: Array<{ name: string; start: number; end: number; level: number }> = []
while ((match = fieldStartRegex.exec(outputsContent)) !== null) {
const fieldName = match[1]
const bracePos = match.index + match[0].length - 1
// Skip if already resolved as const reference
if (outputs[fieldName]) {
continue
}
const openBrace = braces.find((b) => b.type === 'open' && b.pos === bracePos)
if (openBrace) {
const endPos = findMatchingClose(outputsContent, bracePos)
if (endPos !== -1) {
fieldPositions.push({
name: fieldName,
start: bracePos,
end: endPos,
level: openBrace.level,
})
}
}
}
const topLevelFields = fieldPositions.filter((f) => f.level === 0)
topLevelFields.forEach((field) => {
const fieldContent = outputsContent.substring(field.start + 1, field.end - 1).trim()
const parsedField = parseFieldContent(fieldContent, toolPrefix)
if (parsedField) {
outputs[field.name] = parsedField
}
})
return outputs
}
/**
* Returns true if the regex match at `matchIndex` within `content` is at brace depth 0.
* Used to distinguish top-level keys from keys nested inside child objects.
*/
function isAtDepthZero(content: string, matchIndex: number): boolean {
let depth = 0
for (let i = 0; i < matchIndex; i++) {
if (content[i] === '{') depth++
else if (content[i] === '}') depth--
}
return depth === 0
}
function parseFieldContent(fieldContent: string, toolPrefix?: string): any {
// Only match `type:` that is at the top level of fieldContent (depth 0).
// Child objects like `title: { type: 'string', ... }` also contain `type:` but at depth 1.
const typeRegex = /type\s*:\s*['"]([^'"]+)['"]/g
let typeMatch: RegExpExecArray | null = null
let m: RegExpExecArray | null
while ((m = typeRegex.exec(fieldContent)) !== null) {
if (isAtDepthZero(fieldContent, m.index)) {
typeMatch = m
break
}
}
const description = extractDescription(fieldContent)
// Check for spread operator at the start of field content (e.g., ...SUBSCRIPTION_OUTPUT)
// This pattern is used when a field spreads a complete output definition and optionally overrides properties
const spreadMatch = fieldContent.match(/^\s*\.\.\.([A-Z][A-Z_0-9]+)\s*,/)
if (spreadMatch && toolPrefix && !typeMatch) {
const constName = spreadMatch[1]
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst && typeof resolvedConst === 'object') {
// Start with the resolved const and override with inline properties
const result: any = { ...resolvedConst }
// Override description if provided inline
if (description) {
result.description = description
}
return result
}
}
if (!typeMatch) {
// No top-level `type` key — check if the content contains named child fields that each
// have their own `type` property. This is the "implicit object" pattern used in trigger
// outputs (e.g., Cal.com's `payload`, Linear's `data`).
const properties = parsePropertiesContent(fieldContent, toolPrefix)
if (Object.keys(properties).length > 0) {
return {
type: 'object',
description: description || '',
properties,
}
}
return null
}
const fieldType = typeMatch[1]
const result: any = {
type: fieldType,
description: description || '',
}
if (fieldType === 'object' || fieldType === 'json') {
// Check for const reference first (e.g., properties: SCHEDULE_DATA_OUTPUT_PROPERTIES)
const propsConstMatch = fieldContent.match(/properties\s*:\s*([A-Z][A-Z_0-9]+)/)
if (propsConstMatch && toolPrefix) {
const resolvedProps = resolveConstReference(propsConstMatch[1], toolPrefix)
if (resolvedProps) {
result.properties = resolvedProps
}
} else {
// Check for inline properties
const propertiesRegex = /properties\s*:\s*{/
const propertiesStart = fieldContent.search(propertiesRegex)
if (propertiesStart !== -1) {
const braceStart = fieldContent.indexOf('{', propertiesStart)
const braceEnd = findMatchingClose(fieldContent, braceStart)
if (braceEnd !== -1) {
const propertiesContent = fieldContent.substring(braceStart + 1, braceEnd - 1).trim()
result.properties = parsePropertiesContent(propertiesContent, toolPrefix)
}
}
}
}
// Check for items const reference (e.g., items: ATTENDEES_OUTPUT)
const itemsConstMatch = fieldContent.match(/items\s*:\s*([A-Z][A-Z_0-9]+)/)
if (itemsConstMatch && toolPrefix) {
const resolvedItems = resolveConstReference(itemsConstMatch[1], toolPrefix)
if (resolvedItems) {
result.items = resolvedItems
}
} else {
const itemsRegex = /items\s*:\s*{/
const itemsStart = fieldContent.search(itemsRegex)
if (itemsStart !== -1) {
const braceStart = fieldContent.indexOf('{', itemsStart)
const braceEnd = findMatchingClose(fieldContent, braceStart)
if (braceEnd !== -1) {
const itemsContent = fieldContent.substring(braceStart + 1, braceEnd - 1).trim()
const itemsType = itemsContent.match(/type\s*:\s*['"]([^'"]+)['"]/)
// Check for inline properties FIRST (properties: {), then const reference
const propertiesInlineStart = itemsContent.search(/properties\s*:\s*{/)
// Only match const reference if it's at the TOP level (before any {)
const itemsPropsConstMatch =
propertiesInlineStart === -1
? itemsContent.match(/properties\s*:\s*([A-Z][A-Z_0-9]+)/)
: null
const searchContent =
propertiesInlineStart >= 0
? itemsContent.substring(0, propertiesInlineStart)
: itemsContent
const itemsDesc = extractDescription(searchContent)
result.items = {
type: itemsType ? itemsType[1] : 'object',
description: itemsDesc || '',
}
if (itemsPropsConstMatch && toolPrefix) {
const resolvedProps = resolveConstReference(itemsPropsConstMatch[1], toolPrefix)
if (resolvedProps) {
result.items.properties = resolvedProps
}
} else if (propertiesInlineStart !== -1) {
const itemsPropertiesRegex = /properties\s*:\s*{/
const itemsPropsStart = itemsContent.search(itemsPropertiesRegex)
if (itemsPropsStart !== -1) {
const propsBraceStart = itemsContent.indexOf('{', itemsPropsStart)
let propsBraceCount = 1
let propsBraceEnd = propsBraceStart + 1
while (propsBraceEnd < itemsContent.length && propsBraceCount > 0) {
if (itemsContent[propsBraceEnd] === '{') propsBraceCount++
else if (itemsContent[propsBraceEnd] === '}') propsBraceCount--
propsBraceEnd++
}
if (propsBraceCount === 0) {
const itemsPropsContent = itemsContent
.substring(propsBraceStart + 1, propsBraceEnd - 1)
.trim()
result.items.properties = parsePropertiesContent(itemsPropsContent, toolPrefix)
}
}
}
}
}
}
return result
}
function parsePropertiesContent(
propertiesContent: string,
toolPrefix?: string
): Record<string, any> {
const properties: Record<string, any> = {}
// First, handle const references at the property level
// Patterns: "attendees: ATTENDEES_OUTPUT" or "id: BOOKING_DATA_OUTPUT_PROPERTIES.id"
if (toolPrefix) {
// Pattern 1: Direct const reference (e.g., "eventType: EVENT_TYPE_OUTPUT,")
const constRefRegex = /(\w+)\s*:\s*([A-Z][A-Z_0-9]+)\s*(?:,|$)/g
let constMatch
while ((constMatch = constRefRegex.exec(propertiesContent)) !== null) {
const propName = constMatch[1]
const constName = constMatch[2]
// Skip keywords
if (propName === 'items' || propName === 'properties' || propName === 'type') {
continue
}
// Check if at depth 0
const beforeMatch = propertiesContent.substring(0, constMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst) {
properties[propName] = resolvedConst
}
}
// Pattern 2: Property access on const (e.g., "id: BOOKING_DATA_OUTPUT_PROPERTIES.id,")
const propAccessRegex = /(\w+)\s*:\s*([A-Z][A-Z_0-9]+)\.(\w+)\s*(?:,|$)/g
let propAccessMatch
while ((propAccessMatch = propAccessRegex.exec(propertiesContent)) !== null) {
const propName = propAccessMatch[1]
const constName = propAccessMatch[2]
const accessedProp = propAccessMatch[3]
// Skip keywords
if (propName === 'items' || propName === 'properties' || propName === 'type') {
continue
}
// Skip if already resolved
if (properties[propName]) {
continue
}
// Check if at depth 0
const beforeMatch = propertiesContent.substring(0, propAccessMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst?.[accessedProp]) {
properties[propName] = resolvedConst[accessedProp]
}
}
// Pattern 3: Spread operator (e.g., "...COMMENT_OUTPUT_PROPERTIES,")
const spreadRegex = /\.\.\.([A-Z][A-Z_0-9]+)\s*(?:,|$)/g
let spreadMatch
while ((spreadMatch = spreadRegex.exec(propertiesContent)) !== null) {
const constName = spreadMatch[1]
// Check if at depth 0
const beforeMatch = propertiesContent.substring(0, spreadMatch.index)
const openBraces = (beforeMatch.match(/\{/g) || []).length
const closeBraces = (beforeMatch.match(/\}/g) || []).length
if (openBraces !== closeBraces) {
continue
}
const resolvedConst = resolveConstReference(constName, toolPrefix)
if (resolvedConst && typeof resolvedConst === 'object') {
// Spread all properties from the resolved const
Object.assign(properties, resolvedConst)
}
}
}
const propStartRegex = /(\w+)\s*:\s*{/g
let match
const propPositions: Array<{ name: string; start: number; content: string }> = []
while ((match = propStartRegex.exec(propertiesContent)) !== null) {
const propName = match[1]
if (propName === 'items' || propName === 'properties') {
continue
}
// Skip if already resolved as const reference
if (properties[propName]) {
continue
}
// Check if this match is at depth 0 (not inside nested braces)
// Only process top-level properties, skip nested ones
const beforeMatch = propertiesContent.substring(0, match.index)
const openBraces = (beforeMatch.match(/{/g) || []).length
const closeBraces = (beforeMatch.match(/}/g) || []).length
if (openBraces !== closeBraces) {
continue // Skip - this is a nested property
}
const startPos = match.index + match[0].length - 1
const endPos = findMatchingClose(propertiesContent, startPos)
if (endPos !== -1) {
const propContent = propertiesContent.substring(startPos + 1, endPos - 1).trim()
const hasDescription = /description\s*:\s*/.test(propContent)
const hasProperties = /properties\s*:\s*[{A-Z]/.test(propContent)
const hasItems = /items\s*:\s*[{A-Z]/.test(propContent)
const isTypeOnly =
!hasDescription &&
!hasProperties &&
!hasItems &&
/^type\s*:\s*['"].*?['"]\s*,?\s*$/.test(propContent)
if (!isTypeOnly) {
propPositions.push({
name: propName,
start: startPos,
content: propContent,
})
}
}
}
propPositions.forEach((prop) => {
const parsedProp = parseFieldContent(prop.content, toolPrefix)
if (parsedProp) {
properties[prop.name] = parsedProp
}
})
return properties
}
async function getToolInfo(toolName: string): Promise<{
description: string
params: Array<{ name: string; type: string; required: boolean; description: string }>
outputs: Record<string, any>
} | null> {
try {
const parts = toolName.split('_')
let toolPrefix = ''
let toolSuffix = ''
for (let i = parts.length - 1; i >= 1; i--) {
const possiblePrefix = parts.slice(0, i).join('_')
const possibleSuffix = parts.slice(i).join('_')
const toolDirPath = path.join(rootDir, `apps/sim/tools/${possiblePrefix}`)
if (fs.existsSync(toolDirPath) && fs.statSync(toolDirPath).isDirectory()) {
toolPrefix = possiblePrefix
toolSuffix = possibleSuffix
break
}
}
if (!toolPrefix) {
toolPrefix = parts[0]
toolSuffix = parts.slice(1).join('_')
}
// Check if this is a versioned tool (e.g., _v2, _v3)
const isVersionedTool = isVersionedType(toolSuffix)
const strippedToolSuffix = stripVersionSuffix(toolSuffix)
const possibleLocations: Array<{ path: string; priority: 'exact' | 'fallback' }> = []
// For versioned tools, prioritize the exact versioned file first
// This handles cases like google_sheets where V2 is in a separate file (read_v2.ts)
if (isVersionedTool) {
// First priority: exact versioned file (e.g., read_v2.ts)
possibleLocations.push({
path: path.join(rootDir, `apps/sim/tools/${toolPrefix}/${toolSuffix}.ts`),
priority: 'exact',
})
// Second priority: stripped file that contains both V1 and V2 (e.g., pr.ts for github)
possibleLocations.push({
path: path.join(rootDir, `apps/sim/tools/${toolPrefix}/${strippedToolSuffix}.ts`),
priority: 'fallback',
})
} else {
// Non-versioned tool: try the direct file
possibleLocations.push({
path: path.join(rootDir, `apps/sim/tools/${toolPrefix}/${toolSuffix}.ts`),
priority: 'exact',
})
}
// Also try camelCase versions
const camelCaseSuffix = strippedToolSuffix
.split('_')
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
.join('')
possibleLocations.push({
path: path.join(rootDir, `apps/sim/tools/${toolPrefix}/${camelCaseSuffix}.ts`),
priority: 'fallback',
})
// Fall back to index.ts
possibleLocations.push({
path: path.join(rootDir, `apps/sim/tools/${toolPrefix}/index.ts`),
priority: 'fallback',
})
let toolFileContent = ''
let foundFile = ''
// Try to find a file that contains the exact tool ID
for (const location of possibleLocations) {
if (fs.existsSync(location.path)) {
const content = fs.readFileSync(location.path, 'utf-8')
// Check if this file contains the exact tool ID we're looking for
const toolIdRegex = new RegExp(`id:\\s*['"]${toolName}['"]`)
if (toolIdRegex.test(content)) {
toolFileContent = content
foundFile = location.path
break
}
// For fallback locations, store the content in case we don't find an exact match
if (location.priority === 'fallback' && !toolFileContent) {
toolFileContent = content
foundFile = location.path
}
}
}
// If we didn't find a file with the exact ID, use the first available file
if (!toolFileContent) {
for (const location of possibleLocations) {
if (fs.existsSync(location.path)) {
toolFileContent = fs.readFileSync(location.path, 'utf-8')
foundFile = location.path
break
}
}
}
if (!toolFileContent) {
console.warn(`Could not find definition for tool: ${toolName}`)
return null
}
return extractToolInfo(toolName, toolFileContent)
} catch (error) {
console.error(`Error getting info for tool ${toolName}:`, error)
return null
}
}
function extractManualContent(existingContent: string): Record<string, string> {
const manualSections: Record<string, string> = {}
const manualContentRegex =
/\{\/\*\s*MANUAL-CONTENT-START:(\w+)\s*\*\/\}([\s\S]*?)\{\/\*\s*MANUAL-CONTENT-END\s*\*\/\}/g
let match
while ((match = manualContentRegex.exec(existingContent)) !== null) {
const sectionName = match[1]
const content = match[2].trim()
manualSections[sectionName] = content
}
return manualSections
}
function mergeWithManualContent(
generatedMarkdown: string,
existingContent: string | null,
manualSections: Record<string, string>
): string {
if (!existingContent || Object.keys(manualSections).length === 0) {
return generatedMarkdown
}
let mergedContent = generatedMarkdown
Object.entries(manualSections).forEach(([sectionName, content]) => {
const insertionPoints: Record<string, { regex: RegExp }> = {
intro: {
regex: /<BlockInfoCard[\s\S]*?(\/>|<\/svg>`}\s*\/>)/,
},
usage: {
regex: /## Usage Instructions/,
},
outputs: {
regex: /## Outputs/,
},
notes: {
regex: /## Notes/,
},
}
const insertionPoint = insertionPoints[sectionName]
if (insertionPoint) {
const match = mergedContent.match(insertionPoint.regex)
if (match && match.index !== undefined) {
const insertPosition = match.index + match[0].length
mergedContent = `${mergedContent.slice(0, insertPosition)}\n\n{/* MANUAL-CONTENT-START:${sectionName} */}\n${content}\n{/* MANUAL-CONTENT-END */}\n${mergedContent.slice(insertPosition)}`
} else {
console.log(
`Could not find insertion point for ${sectionName}, regex pattern: ${insertionPoint.regex}`
)
}
} else {
console.log(`No insertion point defined for section ${sectionName}`)
}
})
return mergedContent
}
async function generateBlockDoc(blockPath: string) {
try {
const blockFileName = path.basename(blockPath, '.ts')
if (blockFileName.endsWith('.test')) {
return
}
const fileContent = fs.readFileSync(blockPath, 'utf-8')
// Extract ALL block configs from the file (already filters out hideFromToolbar: true)
const blockConfigs = extractAllBlockConfigs(fileContent)
if (blockConfigs.length === 0) {
console.warn(`Skipping ${blockFileName} - no valid block configs found`)
return
}
// Process each block config
for (const blockConfig of blockConfigs) {
if (!blockConfig.type) {
continue
}
// Canonical integrations filter: docs are written only for third-party tool blocks
// visible in the toolbar. Source of truth: `isIntegrationBlock`.
if (!isIntegrationBlock(blockConfig)) continue
// Use stripped type for file name (removes _v2, _v3 suffixes for cleaner URLs)
const displayType = stripVersionSuffix(blockConfig.type)
const outputFilePath = path.join(DOCS_OUTPUT_PATH, `${displayType}.mdx`)
let existingContent: string | null = null
if (fs.existsSync(outputFilePath)) {
existingContent = fs.readFileSync(outputFilePath, 'utf-8')
}
const manualSections = existingContent ? extractManualContent(existingContent) : {}
const markdown = await generateMarkdownForBlock(blockConfig, displayType)
let finalContent = markdown
if (Object.keys(manualSections).length > 0) {
finalContent = mergeWithManualContent(markdown, existingContent, manualSections)
}
fs.writeFileSync(outputFilePath, finalContent)
const logType =
displayType !== blockConfig.type ? `${displayType} (from ${blockConfig.type})` : displayType
console.log(`✓ Generated docs for ${logType}`)
}
} catch (error) {
console.error(`Error processing ${blockPath}:`, error)
}
}
async function generateMarkdownForBlock(
blockConfig: BlockConfig,
displayType?: string
): Promise<string> {
const {
type,
name,
description,
longDescription,
bgColor,
outputs = {},
tools = { access: [] },
} = blockConfig
let outputsSection = ''
if (outputs && Object.keys(outputs).length > 0) {
outputsSection = '## Outputs\n\n'
outputsSection += '| Output | Type | Description |\n'
outputsSection += '| ------ | ---- | ----------- |\n'
for (const outputKey in outputs) {
const output = outputs[outputKey]
const escapedDescription = output.description
? output.description
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
: `Output from ${outputKey}`
if (typeof output.type === 'string') {
outputsSection += `| \`${outputKey}\` | ${output.type} | ${escapedDescription} |\n`
} else if (output.type && typeof output.type === 'object') {
outputsSection += `| \`${outputKey}\` | object | ${escapedDescription} |\n`
for (const propName in output.type) {
const propType = output.type[propName]
const commentMatch =
propName && output.type[propName]._comment
? output.type[propName]._comment
: `${propName} of the ${outputKey}`
outputsSection += `| ↳ \`${propName}\` | ${propType} | ${commentMatch} |\n`
}
} else if (output.properties) {
outputsSection += `| \`${outputKey}\` | object | ${escapedDescription} |\n`
for (const propName in output.properties) {
const prop = output.properties[propName]
const escapedPropertyDescription = prop.description
? prop.description
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
: `The ${propName} of the ${outputKey}`
outputsSection += `| ↳ \`${propName}\` | ${prop.type} | ${escapedPropertyDescription} |\n`
}
}
}
} else {
outputsSection = 'This block does not produce any outputs.'
}
let toolsSection = ''
if (tools.access?.length) {
toolsSection = '## Tools\n\n'
for (const tool of tools.access) {
// Strip version suffix from tool name for display
const displayToolName = stripVersionSuffix(tool)
toolsSection += `### \`${displayToolName}\`\n\n`
console.log(`Getting info for tool: ${tool}`)
const toolInfo = await getToolInfo(tool)
if (toolInfo) {
if (toolInfo.description && toolInfo.description !== 'No description available') {
const escapedToolDescription = toolInfo.description
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
toolsSection += `${escapedToolDescription}\n\n`
}
toolsSection += '#### Input\n\n'
toolsSection += '| Parameter | Type | Required | Description |\n'
toolsSection += '| --------- | ---- | -------- | ----------- |\n'
if (toolInfo.params.length > 0) {
for (const param of toolInfo.params) {
const escapedDescription = param.description
? param.description
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
: 'No description'
toolsSection += `| \`${param.name}\` | ${param.type} | ${param.required ? 'Yes' : 'No'} | ${escapedDescription} |\n`
}
}
toolsSection += '\n#### Output\n\n'
if (Object.keys(toolInfo.outputs).length > 0) {
toolsSection += '| Parameter | Type | Description |\n'
toolsSection += '| --------- | ---- | ----------- |\n'
toolsSection += formatOutputStructure(toolInfo.outputs)
} else if (Object.keys(outputs).length > 0) {
toolsSection += '| Parameter | Type | Description |\n'
toolsSection += '| --------- | ---- | ----------- |\n'
for (const [key, output] of Object.entries(outputs)) {
let type = 'string'
let description = `${key} output from the tool`
if (typeof output === 'string') {
type = output
} else if (typeof output === 'object' && output !== null) {
if ('type' in output && typeof output.type === 'string') {
type = output.type
}
if ('description' in output && typeof output.description === 'string') {
description = output.description
}
}
const escapedDescription = description
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
toolsSection += `| \`${key}\` | ${type} | ${escapedDescription} |\n`
}
} else {
toolsSection += 'This tool does not produce any outputs.\n'
}
}
toolsSection += '\n'
}
}
let usageInstructions = ''
if (longDescription) {
usageInstructions = `## Usage Instructions\n\n${longDescription}\n\n`
}
return `---
title: ${name}
description: ${description}
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="${type}"
color="${bgColor || '#F5F5F5'}"
/>
${usageInstructions}
${toolsSection}
`
}
/**
* Compute the canonical set of stripped block types that should have a
* `docs/tools/*.mdx` file — namely every visible `category: 'tools'` block
* (matching the writer filter at the top of this script). Any existing MDX
* not in this set is stale and gets cleaned up.
*
* Uses `extractAllBlockConfigs` so spread-inherited fields (e.g. a V2 that
* spreads `...GmailBlock` and inherits `category: 'tools'`) are resolved the
* same way the writer resolves them. `stripVersionSuffix` ensures V1 and V2
* map to the same doc filename — alphabetical glob order means the newest
* version naturally wins for both generation and cleanup.
*/
async function getCanonicalToolDocNames(): Promise<Set<string>> {
const validToolDocs = new Set<string>()
const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort()
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
const configs = extractAllBlockConfigs(fileContent)
for (const config of configs) {
if (!isIntegrationBlock(config)) continue
validToolDocs.add(stripVersionSuffix(config.type))
}
}
return validToolDocs
}
/**
* Remove any `docs/tools/*.mdx` that no longer corresponds to a visible
* `category: 'tools'` block — covers both hidden blocks and blocks that
* have been re-categorized to `'blocks'` / `'triggers'`. Keeps the
* tools/ docs directory in lockstep with the canonical block registry.
*/
function cleanupStaleToolDocs(validToolDocs: Set<string>): void {
console.log('Cleaning up stale tool docs...')
const existingDocs = fs
.readdirSync(DOCS_OUTPUT_PATH)
.filter((file: string) => file.endsWith('.mdx'))
let removedCount = 0
for (const docFile of existingDocs) {
const blockType = path.basename(docFile, '.mdx')
if (blockType === 'index') continue
if (validToolDocs.has(blockType)) continue
const docPath = path.join(DOCS_OUTPUT_PATH, docFile)
fs.unlinkSync(docPath)
console.log(`✓ Removed stale tool doc: ${blockType}.mdx`)
removedCount++
}
if (removedCount > 0) {
console.log(`✓ Cleaned up ${removedCount} stale tool doc files`)
} else {
console.log('✓ No stale tool docs to clean up')
}
}
// ============================================================================
// Trigger Documentation Generation
// ============================================================================
/**
* Format a trigger provider name for display, falling back to Title Case.
*/
function formatTriggerProviderName(provider: string): string {
if (TRIGGER_PROVIDER_DISPLAY_NAMES[provider]) {
return TRIGGER_PROVIDER_DISPLAY_NAMES[provider]
}
return provider.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
/**
* Escape text for use inside an MDX table cell.
*/
function escapeMdxCell(text: string): string {
return text
.replace(/\|/g, '\\|')
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/**
* Resolve a module-level `const varName = { ... }` declaration.
* Handles nested spreads of other const variables (but not property-access values).
* Used to expand variable spreads inside builder function return bodies.
*/
function resolveConstVariable(
varName: string,
primaryContent: string,
utilsContent: string,
depth = 0
): Record<string, any> {
if (depth > 8) return {}
// Match `const varName = {` (with optional type annotation)
const varRegex = new RegExp(`(?<![.\\w])const\\s+${varName}\\s*(?::[^=]+)?=\\s*\\{`)
for (const content of [primaryContent, utilsContent]) {
const varMatch = varRegex.exec(content)
if (!varMatch) continue
const openBrace = content.indexOf('{', varMatch.index + varMatch[0].length - 1)
if (openBrace === -1) continue
const closeBrace = findMatchingClose(content, openBrace)
if (closeBrace === -1) continue
const varBody = content.substring(openBrace + 1, closeBrace - 1).trim()
const result: Record<string, any> = {}
// Resolve nested variable spreads within this const (no parens = variable reference)
const nestedSpreadRegex = /\.\.\.\s*([a-zA-Z_]\w*)\b(?!\s*\()/g
let nestedMatch: RegExpExecArray | null
while ((nestedMatch = nestedSpreadRegex.exec(varBody)) !== null) {
const nested = resolveConstVariable(nestedMatch[1], primaryContent, utilsContent, depth + 1)
Object.assign(result, nested)
}
// Parse any inline `field: { type, description }` definitions
// (strip spread lines first; property-access values like `foo: bar.baz` are skipped by parser)
const bodyWithoutVarSpreads = varBody.replace(/\.\.\.\s*\w+\b(?!\s*\()\s*,?\s*/g, '')
const inlineOutputs = parseToolOutputsField(bodyWithoutVarSpreads)
Object.assign(result, inlineOutputs)
return result
}
return {}
}
/**
* Recursively resolve a trigger output builder function.
* Handles the common pattern where builders spread other builders:
* `return { ...buildBaseOutputs(), fieldA: { type: 'string', ... } }`
* Also handles variable spreads:
* `return { ...coreOutputs, ...deploymentOutputs }`
*
* Searches for the function definition in `primaryContent` first, then `utilsContent`.
* Recursion depth is capped to avoid infinite loops.
*/
function resolveTriggerBuilderFunction(
funcName: string,
primaryContent: string,
utilsContent: string,
depth = 0
): Record<string, any> {
if (depth > 8) return {}
const funcRegex = new RegExp(`(?:export\\s+)?function\\s+${funcName}\\s*\\(`)
let funcBody: string | null = null
for (const content of [primaryContent, utilsContent]) {
const funcMatch = funcRegex.exec(content)
if (!funcMatch) continue
const bodyStart = content.indexOf('{', funcMatch.index)
if (bodyStart === -1) continue
const bodyEnd = findMatchingClose(content, bodyStart)
if (bodyEnd === -1) continue
funcBody = content.substring(bodyStart + 1, bodyEnd - 1)
break
}
if (!funcBody) return {}
// Handle `return anotherFunc(...)` — full delegation to another builder,
// with or without arguments (argument values are ignored; only structure matters).
const returnFuncCallMatch = /\breturn\s+([a-z][a-zA-Z0-9_]*)\s*\(/.exec(funcBody.trim())
if (returnFuncCallMatch) {
return resolveTriggerBuilderFunction(
returnFuncCallMatch[1],
primaryContent,
utilsContent,
depth + 1
)
}
// Handle `return { ... }` — inline object literal
const returnMatch = /\breturn\s*\{/.exec(funcBody)
if (!returnMatch) return {}
const returnObjStart = funcBody.indexOf('{', returnMatch.index)
const returnObjEnd = findMatchingClose(funcBody, returnObjStart)
if (returnObjEnd === -1) return {}
const returnBody = funcBody.substring(returnObjStart + 1, returnObjEnd - 1).trim()
const result: Record<string, any> = {}
// Expand function-call spreads first: ...innerFuncName()
const spreadFuncRegex = /\.\.\.\s*(\w+)\s*\(\s*\)/g
let spreadMatch: RegExpExecArray | null
while ((spreadMatch = spreadFuncRegex.exec(returnBody)) !== null) {
const innerFuncName = spreadMatch[1]
const resolved = resolveTriggerBuilderFunction(
innerFuncName,
primaryContent,
utilsContent,
depth + 1
)
Object.assign(result, resolved)
}
// Expand variable spreads: ...varName (no parentheses — const references)
const spreadVarRegex = /\.\.\.\s*([a-zA-Z_]\w*)\b(?!\s*\()/g
let spreadVarMatch: RegExpExecArray | null
while ((spreadVarMatch = spreadVarRegex.exec(returnBody)) !== null) {
const varName = spreadVarMatch[1]
const resolved = resolveConstVariable(varName, primaryContent, utilsContent, depth + 1)
Object.assign(result, resolved)
}
// Then parse any inline field definitions (strip all spread lines first)
const bodyWithoutSpreads = returnBody
.replace(/\.\.\.\s*\w+\s*\(\s*\)\s*,?\s*/g, '') // function call spreads
.replace(/\.\.\.\s*\w+\b(?!\s*\()\s*,?\s*/g, '') // variable spreads
const inlineOutputs = parseToolOutputsField(bodyWithoutSpreads)
Object.assign(result, inlineOutputs)
return result
}
/**
* Extract the outputs object from a TriggerConfig segment.
* Handles both inline `outputs: { ... }` and function-call patterns
* like `outputs: buildIssueOutputs()`, resolving builder functions
* from the trigger file itself and its sibling `utils.ts`.
*/
function extractTriggerOutputs(
segment: string,
fileContent: string,
utilsContent: string
): Record<string, any> {
// 1. Inline outputs: outputs: { ... }
const outputsMatch = /\boutputs\s*:\s*\{/.exec(segment)
if (outputsMatch) {
const openPos = segment.indexOf('{', outputsMatch.index + outputsMatch[0].length - 1)
if (openPos !== -1) {
const closePos = findMatchingClose(segment, openPos)
if (closePos !== -1) {
const outputsContent = segment.substring(openPos + 1, closePos - 1).trim()
return parseToolOutputsField(outputsContent)
}
}
}
// 2. Function-call outputs: outputs: buildFoo()
const funcCallMatch = /\boutputs\s*:\s*(\w+)\s*\(\s*\)/.exec(segment)
if (funcCallMatch) {
return resolveTriggerBuilderFunction(funcCallMatch[1], fileContent, utilsContent)
}
return {}
}
/**
* Lazy-loaded cache of all TypeScript files in `lib/webhooks/providers/`.
* Used to resolve exported string constants that are imported by trigger utils files
* (e.g. `GONG_JWT_PUBLIC_KEY_CONFIG_KEY` from `lib/webhooks/providers/gong.ts`).
*/
let _webhookProviderConstantsCache: string | null = null
function getWebhookProviderConstants(): string {
if (_webhookProviderConstantsCache === null) {
const dir = path.join(rootDir, 'apps/sim/lib/webhooks/providers')
if (fs.existsSync(dir)) {
_webhookProviderConstantsCache = fs
.readdirSync(dir)
.filter((f) => f.endsWith('.ts'))
.map((f) => fs.readFileSync(path.join(dir, f), 'utf-8'))
.join('\n')
} else {
_webhookProviderConstantsCache = ''
}
}
return _webhookProviderConstantsCache
}
/**
* Try to resolve a SCREAMING_SNAKE_CASE constant to its string value by
* searching in the given content AND the webhook provider constants cache.
*/
function resolveConstStringValue(constName: string, content: string): string | null {
const pattern = new RegExp(`\\b${constName}\\s*=\\s*['"]([^'"]+)['"]`)
return pattern.exec(content)?.[1] ?? pattern.exec(getWebhookProviderConstants())?.[1] ?? null
}
/**
* Parse a single SubBlockConfig object literal into a TriggerConfigField.
* Returns null for blocks that should be skipped (UI-only IDs, text type, readOnly).
* Accepts optional `resolverContent` to resolve const-reference field IDs.
*/
function parseSubBlockObject(
obj: string,
uiOnlyIds: Set<string>,
resolverContent?: string
): TriggerConfigField | null {
let id: string | undefined = /\bid\s*:\s*['"]([^'"]+)['"]/.exec(obj)?.[1]
// Handle const-reference ids: `id: SCREAMING_CASE_IDENTIFIER`
if (!id) {
const constRefMatch = /\bid\s*:\s*([A-Z][A-Z0-9_]+)\b/.exec(obj)
if (constRefMatch) {
id = resolveConstStringValue(constRefMatch[1], resolverContent ?? '') ?? undefined
}
}
if (!id || uiOnlyIds.has(id)) return null
const typeMatch = /\btype\s*:\s*['"]([^'"]+)['"]/.exec(obj)
if (typeMatch?.[1] === 'text') return null
if (/\breadOnly\s*:\s*true/.test(obj)) return null
const titleMatch = /\btitle\s*:\s*['"]([^'"]+)['"]/.exec(obj)
const requiredMatch = /\brequired\s*:\s*(true)/.exec(obj)
const placeholderMatch = /\bplaceholder\s*:\s*['"]([^'"]+)['"]/.exec(obj)
const descMatch = /\bdescription\s*:\s*['"]([^'"]+)['"]/.exec(obj)
// Use title as description fallback so oauth-input and other fields without
// an explicit description still show something meaningful in the docs table.
const description = descMatch?.[1] ?? (titleMatch ? `${titleMatch[1]}` : undefined)
return {
id,
title: titleMatch?.[1] ?? id,
type: typeMatch?.[1] ?? 'short-input',
required: Boolean(requiredMatch),
placeholder: placeholderMatch?.[1],
description,
}
}
/**
* Resolve a SubBlockConfig builder function to its field definitions.
* Handles `return [...]`, `return {...}`, and `blocks.push(...)` patterns.
* Searches `utilsContent` first, then `primaryContent`.
*/
function resolveSubBlockBuilderFunction(
funcName: string,
utilsContent: string,
primaryContent?: string
): TriggerConfigField[] {
const UI_ONLY_IDS = new Set(['webhookUrlDisplay', 'triggerInstructions', 'selectedTriggerId'])
for (const content of [utilsContent, primaryContent ?? '']) {
if (!content) continue
const funcRegex = new RegExp(`(?:export\\s+)?function\\s+${funcName}\\s*\\(`)
const funcMatch = funcRegex.exec(content)
if (!funcMatch) continue
// Find the closing ')' of the parameter list, then the '{' that opens the function body.
// Using just indexOf('{') would pick up '{' inside object-type parameters.
const openParen = content.indexOf('(', funcMatch.index)
if (openParen === -1) continue
const closeParen = findMatchingClose(content, openParen, '(', ')')
if (closeParen === -1) continue
const bodyStart = content.indexOf('{', closeParen)
if (bodyStart === -1) continue
const bodyEnd = findMatchingClose(content, bodyStart)
if (bodyEnd === -1) continue
const funcBody = content.substring(bodyStart + 1, bodyEnd - 1)
// Pattern 1: `return [...]`
const returnArrayMatch = /\breturn\s*\[/.exec(funcBody)
if (returnArrayMatch) {
const arrayStart = funcBody.indexOf('[', returnArrayMatch.index)
const arrayEnd = findMatchingClose(funcBody, arrayStart, '[', ']')
if (arrayEnd !== -1) {
return parseSubBlockArrayContent(
funcBody.substring(arrayStart + 1, arrayEnd - 1),
UI_ONLY_IDS,
content
)
}
}
// Pattern 2: `return { ... }` (single object)
const returnObjMatch = /\breturn\s*\{/.exec(funcBody)
if (returnObjMatch) {
const objStart = funcBody.indexOf('{', returnObjMatch.index)
const objEnd = findMatchingClose(funcBody, objStart)
if (objEnd !== -1) {
const field = parseSubBlockObject(
funcBody.substring(objStart, objEnd),
UI_ONLY_IDS,
content
)
return field ? [field] : []
}
}
// Pattern 3: `blocks.push({...})`
const pushFields: TriggerConfigField[] = []
const pushRegex = /\bblocks\.push\s*\(/g
let pushMatch: RegExpExecArray | null
while ((pushMatch = pushRegex.exec(funcBody)) !== null) {
const parenStart = pushMatch.index + pushMatch[0].length - 1
const parenEnd = findMatchingClose(funcBody, parenStart, '(', ')')
if (parenEnd === -1) continue
const pushArg = funcBody.substring(parenStart + 1, parenEnd - 1).trim()
if (pushArg.startsWith('{')) {
const field = parseSubBlockObject(pushArg, UI_ONLY_IDS, content)
if (field) pushFields.push(field)
}
}
if (pushFields.length > 0) return pushFields
}
return []
}
/**
* Parse SubBlockConfig items from within an array body (between the brackets).
* Handles inline `{...}` objects and function calls `funcName(...)`.
*/
function parseSubBlockArrayContent(
arrayContent: string,
uiOnlyIds: Set<string>,
utilsContent: string
): TriggerConfigField[] {
const fields: TriggerConfigField[] = []
let i = 0
while (i < arrayContent.length) {
if (arrayContent[i] === '{') {
const j = findMatchingClose(arrayContent, i)
if (j === -1) break
const field = parseSubBlockObject(arrayContent.substring(i, j), uiOnlyIds, utilsContent)
if (field) fields.push(field)
i = j
} else if (/[a-zA-Z_]/.test(arrayContent[i])) {
// Possible function call: funcName(args)
const funcCallMatch = /^(\w+)\s*\(/.exec(arrayContent.substring(i))
if (funcCallMatch && utilsContent) {
const funcName = funcCallMatch[1]
if (funcName !== 'true' && funcName !== 'false' && funcName !== 'null') {
fields.push(...resolveSubBlockBuilderFunction(funcName, utilsContent))
}
// Advance past the function call's closing paren
const openIdx = arrayContent.indexOf('(', i + funcName.length)
if (openIdx !== -1) {
const closeIdx = findMatchingClose(arrayContent, openIdx, '(', ')')
i = closeIdx !== -1 ? closeIdx : openIdx + 1
} else {
i += funcName.length
}
} else {
i++
}
} else {
i++
}
}
return fields
}
/**
* Extract user-facing configuration fields from a TriggerConfig subBlocks definition.
* Handles both inline arrays (`subBlocks: [...]`) and builder function calls
* (`subBlocks: buildXSubBlocks({...})`), resolving them from the trigger file and utils.ts.
*/
function extractTriggerConfigFields(
segment: string,
primaryContent?: string,
utilsContent?: string
): TriggerConfigField[] {
const UI_ONLY_IDS = new Set(['webhookUrlDisplay', 'triggerInstructions', 'selectedTriggerId'])
const allContent = utilsContent || primaryContent || ''
// Case 1: Inline subBlocks: [...]
const subBlocksMatch = /\bsubBlocks\s*:\s*\[/.exec(segment)
if (subBlocksMatch) {
const arrayStart = subBlocksMatch.index + subBlocksMatch[0].length - 1
const arrayEnd = findMatchingClose(segment, arrayStart, '[', ']')
if (arrayEnd === -1) return []
return parseSubBlockArrayContent(
segment.substring(arrayStart + 1, arrayEnd - 1),
UI_ONLY_IDS,
allContent
)
}
// Case 2: Builder function call — subBlocks: buildXFunc(...)
if (!allContent) return []
const builderCallMatch = /\bsubBlocks\s*:\s*(\w+)\s*\(/.exec(segment)
if (!builderCallMatch) return []
const funcName = builderCallMatch[1]
// Special case: buildTriggerSubBlocks — user config lives in the `extraFields` parameter
if (funcName === 'buildTriggerSubBlocks') {
const openParen = builderCallMatch.index + builderCallMatch[0].length - 1
const closeParen = findMatchingClose(segment, openParen, '(', ')')
if (closeParen === -1) return []
const argsBody = segment.substring(openParen + 1, closeParen - 1)
const extraFieldsMatch = /\bextraFields\s*:\s*/.exec(argsBody)
if (!extraFieldsMatch) return []
// Find first non-whitespace char after "extraFields:"
let valuePos = extraFieldsMatch.index + extraFieldsMatch[0].length
while (valuePos < argsBody.length && /\s/.test(argsBody[valuePos])) valuePos++
if (argsBody[valuePos] === '[') {
// extraFields: [...] — inline array, may contain function calls
const arrayEnd = findMatchingClose(argsBody, valuePos, '[', ']')
if (arrayEnd === -1) return []
return parseSubBlockArrayContent(
argsBody.substring(valuePos + 1, arrayEnd - 1),
UI_ONLY_IDS,
allContent
)
}
// extraFields: buildXFunc(args) — resolve the builder function
const extraFuncMatch = /^(\w+)\s*\(/.exec(argsBody.substring(valuePos))
if (!extraFuncMatch) return []
return resolveSubBlockBuilderFunction(extraFuncMatch[1], allContent)
}
// For all other builders, resolve the function body directly
return resolveSubBlockBuilderFunction(funcName, allContent)
}
/**
* Build the full trigger registry: id → TriggerFullInfo.
* Parses every trigger source file for config fields and output schemas.
*/
async function buildFullTriggerRegistry(): Promise<Map<string, TriggerFullInfo>> {
const registry = new Map<string, TriggerFullInfo>()
const SKIP = new Set(['index.ts', 'registry.ts', 'types.ts', 'constants.ts', 'utils.ts'])
const triggerFiles = (await glob(`${TRIGGERS_PATH}/**/*.ts`)).filter(
(f) => !SKIP.has(path.basename(f)) && !f.includes('.test.')
)
for (const file of triggerFiles) {
try {
const content = fs.readFileSync(file, 'utf-8')
// Load sibling utils.ts for resolving builder function outputs
const utilsPath = path.join(path.dirname(file), 'utils.ts')
const utilsContent = fs.existsSync(utilsPath) ? fs.readFileSync(utilsPath, 'utf-8') : ''
const exportRegex = /export\s+const\s+\w+\s*:\s*TriggerConfig\s*=\s*\{/g
let exportMatch: RegExpExecArray | null
const exportStarts: number[] = []
while ((exportMatch = exportRegex.exec(content)) !== null) {
exportStarts.push(exportMatch.index)
}
const segments =
exportStarts.length > 0
? exportStarts.map((start, i) => content.substring(start, exportStarts[i + 1]))
: [content]
for (const segment of segments) {
const idMatch = /\bid\s*:\s*['"]([^'"]+)['"]/.exec(segment)
const nameMatch = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(segment)
const descMatch = /\bdescription\s*:\s*['"]([^'"]+)['"]/.exec(segment)
const providerMatch = /\bprovider\s*:\s*['"]([^'"]+)['"]/.exec(segment)
if (!idMatch || !nameMatch || !providerMatch) continue
const polling = /\bpolling\s*:\s*true/.test(segment)
registry.set(idMatch[1], {
id: idMatch[1],
name: nameMatch[1],
description: descMatch?.[1] ?? '',
provider: providerMatch[1],
polling,
outputs: extractTriggerOutputs(segment, content, utilsContent),
configFields: extractTriggerConfigFields(segment, content, utilsContent),
})
}
} catch {
// skip unreadable files silently
}
}
console.log(`✓ Loaded full config for ${registry.size} triggers`)
return registry
}
/**
* Return the numeric version suffix of a trigger ID (e.g. `_v2` → 2, none → 1).
* Used to prefer the latest version when the same trigger name has v1 and v2 variants.
*/
function triggerVersionOrdinal(id: string): number {
const m = /_v(\d+)$/.exec(id)
return m ? Number.parseInt(m[1], 10) : 1
}
/**
* Group triggers by provider; triggers within each group are sorted alphabetically.
* When multiple triggers share the same display name (e.g. v1 + v2 of the same event),
* only the highest-version variant is kept so docs don't show duplicate sections.
*/
function groupTriggersByProvider(
registry: Map<string, TriggerFullInfo>
): Map<string, TriggerFullInfo[]> {
const groups = new Map<string, TriggerFullInfo[]>()
for (const trigger of registry.values()) {
const bucket = groups.get(trigger.provider) ?? []
bucket.push(trigger)
groups.set(trigger.provider, bucket)
}
for (const [provider, triggers] of groups) {
// Deduplicate by name: keep the highest-versioned trigger for each display name
const byName = new Map<string, TriggerFullInfo>()
for (const trigger of triggers) {
const existing = byName.get(trigger.name)
if (!existing || triggerVersionOrdinal(trigger.id) > triggerVersionOrdinal(existing.id)) {
byName.set(trigger.name, trigger)
}
}
groups.set(
provider,
[...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
)
}
return groups
}
/**
* Map subBlock UI type identifiers to semantic data types for documentation.
* Users care about the data type (string/boolean/number), not the UI widget.
*/
const SUBBLOCK_TYPE_TO_SEMANTIC: Record<string, string> = {
'short-input': 'string',
'long-input': 'string',
dropdown: 'string',
switch: 'boolean',
slider: 'number',
'oauth-input': 'string',
code: 'string',
'file-upload': 'string',
text: 'string',
}
function toSemanticType(uiType: string): string {
return SUBBLOCK_TYPE_TO_SEMANTIC[uiType] ?? uiType
}
/**
* Generate MDX content for a single trigger provider page.
* Matches the structure of tool docs: ## Triggers, ### `trigger_id`, #### Configuration / Output.
*/
function generateTriggerProviderDoc(
provider: string,
triggers: TriggerFullInfo[],
blockType: string,
providerColor: string
): string {
const providerName = formatTriggerProviderName(provider)
const count = triggers.length
const allPolling = triggers.every((t) => t.polling)
const mixedTypes = triggers.some((t) => t.polling) && triggers.some((t) => !t.polling)
let typeNote = ''
if (allPolling) {
typeNote =
'\nAll triggers below are **polling-based** — they check for new data on a schedule rather than receiving push notifications.\n'
} else if (mixedTypes) {
typeNote =
'\nSome triggers below are **polling-based** \\(checked on a schedule\\) while others are push-based webhooks.\n'
}
let triggersSection = ''
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i]
// Configuration table
let configSection = ''
if (trigger.configFields.length > 0) {
configSection = '#### Configuration\n\n'
configSection += '| Parameter | Type | Required | Description |\n'
configSection += '| --------- | ---- | -------- | ----------- |\n'
for (const field of trigger.configFields) {
const type = toSemanticType(field.type)
const desc = escapeMdxCell(field.description ?? field.placeholder ?? '')
configSection += `| \`${field.id}\` | ${type} | ${field.required ? 'Yes' : 'No'} | ${desc} |\n`
}
configSection += '\n'
}
// Output table
let outputSection = ''
if (Object.keys(trigger.outputs).length > 0) {
outputSection = '#### Output\n\n'
outputSection += '| Parameter | Type | Description |\n'
outputSection += '| --------- | ---- | ----------- |\n'
outputSection += formatOutputStructure(trigger.outputs)
outputSection += '\n'
}
const separator = i < triggers.length - 1 ? '\n---\n\n' : ''
triggersSection += `### ${trigger.name}\n\n`
const escapedTriggerDescription = trigger.description
.replace(/\{/g, '\\{')
.replace(/\}/g, '\\}')
triggersSection += `${escapedTriggerDescription}\n\n`
triggersSection += configSection
triggersSection += outputSection
triggersSection += separator
}
return `---
title: ${providerName}
description: Available ${providerName} triggers for automating workflows
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="${blockType}"
color="${providerColor}"
/>
${providerName} provides ${count} trigger${count === 1 ? '' : 's'} for automating workflows based on events.
${typeNote}
## Triggers
${triggersSection}`
}
/**
* Update triggers/meta.json, preserving hand-written entries and appending
* generated provider pages sorted alphabetically.
*/
function updateTriggerMetaJson(generatedProviders: string[]): void {
const metaJsonPath = path.join(TRIGGER_DOCS_OUTPUT_PATH, 'meta.json')
let existingPages: string[] = []
if (fs.existsSync(metaJsonPath)) {
try {
existingPages = JSON.parse(fs.readFileSync(metaJsonPath, 'utf-8')).pages ?? []
} catch {
existingPages = []
}
}
const handWritten = existingPages.filter((p) => HANDWRITTEN_TRIGGER_DOCS.has(p))
const sortedGenerated = [...generatedProviders].sort()
const pages = [...handWritten, ...sortedGenerated]
fs.writeFileSync(metaJsonPath, `${JSON.stringify({ pages }, null, 2)}\n`)
console.log(`✓ Updated trigger meta.json with ${pages.length} entries`)
}
/**
* Build a map of block-type → bgColor from all block definitions.
* Used to pick provider colours for the BlockInfoCard on trigger pages.
*/
async function buildProviderColorMap(): Promise<Map<string, string>> {
const colorMap = new Map<string, string>()
const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort()
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
const configs = extractAllBlockConfigs(fileContent)
for (const config of configs) {
if (config.bgColor && config.type) {
const baseType = stripVersionSuffix(config.type)
if (!colorMap.has(baseType)) colorMap.set(baseType, config.bgColor)
}
}
}
return colorMap
}
/**
* Generate one MDX file per trigger provider and update the sidebar meta.json.
* Hand-written docs (HANDWRITTEN_TRIGGER_DOCS) are never touched.
*/
async function generateAllTriggerDocs(): Promise<void> {
try {
console.log('Generating trigger documentation...')
if (!fs.existsSync(TRIGGER_DOCS_OUTPUT_PATH)) {
fs.mkdirSync(TRIGGER_DOCS_OUTPUT_PATH, { recursive: true })
}
const fullRegistry = await buildFullTriggerRegistry()
const grouped = groupTriggersByProvider(fullRegistry)
const colorMap = await buildProviderColorMap()
const generatedProviders: string[] = []
for (const [provider, triggers] of grouped) {
if (SKIP_TRIGGER_PROVIDERS.has(provider)) {
console.log(`Skipping trigger provider: ${provider} (covered by hand-written docs)`)
continue
}
const outputFilePath = path.join(TRIGGER_DOCS_OUTPUT_PATH, `${provider}.mdx`)
// Never overwrite hand-written docs
if (fs.existsSync(outputFilePath)) {
const baseName = path.basename(outputFilePath, '.mdx')
if (HANDWRITTEN_TRIGGER_DOCS.has(baseName)) {
console.log(`Skipping ${provider} — hand-written doc exists`)
continue
}
}
// Resolve the block type to use for BlockInfoCard (handles provider ≠ block type)
const blockType = PROVIDER_TO_BLOCK_TYPE[provider] ?? provider
const providerColor = colorMap.get(blockType) ?? '#6B7280'
const existingContent = fs.existsSync(outputFilePath)
? fs.readFileSync(outputFilePath, 'utf-8')
: null
// Only preserve manual sections that have actual user-authored content.
// Empty markers (left from a prior generation) are silently discarded so
// they don't accumulate on each subsequent run.
const rawSections = existingContent ? extractManualContent(existingContent) : {}
const manualSections = Object.fromEntries(
Object.entries(rawSections).filter(([, v]) => v.length > 0)
)
const markdown = generateTriggerProviderDoc(provider, triggers, blockType, providerColor)
const finalContent =
Object.keys(manualSections).length > 0
? mergeWithManualContent(markdown, existingContent, manualSections)
: markdown
fs.writeFileSync(outputFilePath, finalContent)
generatedProviders.push(provider)
console.log(
`✓ Generated trigger docs for ${formatTriggerProviderName(provider)} (${triggers.length} trigger${triggers.length === 1 ? '' : 's'})`
)
}
updateTriggerMetaJson(generatedProviders)
console.log(
`✓ Trigger documentation generation complete: ${generatedProviders.length} provider pages`
)
} catch (error) {
console.error('Error generating trigger documentation:', error)
}
}
async function generateAllBlockDocs() {
try {
// Copy icons from sim app to docs app
copyIconsFile()
// Generate icon mappings from block definitions
const docsIconMapping = await generateIconMapping({ includeHidden: true })
const visibleIconMapping = await generateIconMapping({ includeHidden: false })
writeIconMapping(docsIconMapping)
// Generate landing integrations page data (JSON + icon mapping)
await writeIntegrationsJson(visibleIconMapping)
writeIntegrationsIconMapping(visibleIconMapping)
// Compute the canonical set of tool docs and clean up anything stale —
// covers hidden blocks AND blocks re-categorized away from `'tools'`.
const validToolDocs = await getCanonicalToolDocNames()
cleanupStaleToolDocs(validToolDocs)
const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort()
for (const blockFile of blockFiles) {
await generateBlockDoc(blockFile)
}
updateMetaJson()
// Generate trigger provider documentation
await generateAllTriggerDocs()
return true
} catch (error) {
console.error('Error generating documentation:', error)
return false
}
}
function updateMetaJson() {
const metaJsonPath = path.join(DOCS_OUTPUT_PATH, 'meta.json')
const blockFiles = fs
.readdirSync(DOCS_OUTPUT_PATH)
.filter((file: string) => file.endsWith('.mdx'))
.map((file: string) => path.basename(file, '.mdx'))
const items = [
...(blockFiles.includes('index') ? ['index'] : []),
...blockFiles.filter((file: string) => file !== 'index').sort(),
]
const metaJson = {
pages: items,
}
fs.writeFileSync(metaJsonPath, `${JSON.stringify(metaJson, null, 2)}\n`)
console.log(`Updated meta.json with ${items.length} entries`)
}
generateAllBlockDocs()
.then((success) => {
if (success) {
console.log('Documentation generation completed successfully')
process.exit(0)
} else {
console.error('Documentation generation failed')
process.exit(1)
}
})
.catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})