/** * importer-prompt.ts — Generate a structured prompt for Claude to produce a * complete importer pattern suite (auth, auth-manager, API client, importer) * for an arbitrary OAuth2-backed API provider. * * Usage: * import { generateImporterPrompt } from "./importer-prompt.ts"; * * const prompt = generateImporterPrompt({ * providerName: "notion", * brandColor: "#000000", * api: extractedAPI, * providerConfig: providerConfig, * primaryListEndpoint: "/v1/search", * }); * * The returned string is a self-contained prompt that Claude can use to * generate four working pattern files in one shot. * * @module */ import type { ExtractedProviderConfig } from "./openapi-to-provider.ts"; import type { ExtractedAPI, ExtractedEndpoint, ExtractedParameter, PaginationInfo, } from "./openapi-extract.ts"; import { toPascalCase } from "./openapi-utils.ts"; export type { ExtractedAPI, ExtractedEndpoint, ExtractedParameter, PaginationInfo, }; // --------------------------------------------------------------------------- // Prompt context // --------------------------------------------------------------------------- export interface PromptContext { providerName: string; brandColor: string; api: ExtractedAPI; providerConfig: ExtractedProviderConfig; /** Optional: user-provided hint for the primary list endpoint */ primaryListEndpoint?: string; /** Optional: user-provided hint for the primary get endpoint */ primaryGetEndpoint?: string; } // --------------------------------------------------------------------------- // Reference source code (embedded as template literals) // --------------------------------------------------------------------------- // Read from: packages/patterns/airtable/core/airtable-auth.tsx const AIRTABLE_AUTH_SOURCE = `import { computed, Default, handler, ifElse, NAME, pattern, safeDateNow, Stream, TILE_UI, UI, Writable, } from "commonfabric"; type Secret = T; import { createRefreshFunction } from "../../auth/auth-refresh.ts"; import { REFRESH_THRESHOLD_MS, startReactiveClock, } from "../../auth/auth-reactive.ts"; import type { AuthStatus } from "../../auth/auth-types.ts"; import { formatTokenExpiry, getScopeSummary as getScopeSummaryGeneric, getSelectedScopeSummary, STATUS_CONFIG, } from "../../auth/auth-ui-helpers.tsx"; // Airtable scope descriptions const SCOPE_MAP = { "data.records:read": "Read records", "data.records:write": "Write records", "data.recordComments:read": "Read record comments", "data.recordComments:write": "Write record comments", "schema.bases:read": "Read base schemas", "schema.bases:write": "Write base schemas", "webhook:manage": "Manage webhooks", } as const; // Short names for scope summary in the tile preview const SCOPE_SHORT_NAMES: Record = { "data.records:read": "Records (R)", "data.records:write": "Records (W)", "data.recordComments:read": "Comments (R)", "data.recordComments:write": "Comments (W)", "schema.bases:read": "Schema (R)", "schema.bases:write": "Schema (W)", "webhook:manage": "Webhooks", }; /** Get scope summary from granted scope strings */ export function getScopeSummary(grantedScopes: string[]): string { return getScopeSummaryGeneric(grantedScopes, SCOPE_SHORT_NAMES); } /** * Helper to create preview UI for picker display. */ export function createPreviewUI( auth: AirtableAuth | undefined, selectedScopes: Record, ): JSX.Element { const email = auth?.user?.email; const name = auth?.user?.name; const isAuthenticated = !!email; // safeDateNow() capture is intentional — createPreviewUI produces a static // snapshot for picker display, not a live-updating component. The main // pattern UI uses a reactive clock (startReactiveClock) separately. const now = safeDateNow(); const expiresAt = auth?.expiresAt || 0; const isExpired = isAuthenticated && expiresAt > 0 && expiresAt < now; const isWarning = isAuthenticated && !isExpired && expiresAt > 0 && expiresAt - now < 10 * 60 * 1000; const status: AuthStatus = !isAuthenticated ? "needs-login" : isExpired ? "expired" : isWarning ? "warning" : "ready"; const scopeSummary = isAuthenticated ? getScopeSummary(auth?.scope || []) : getSelectedScopeSummary(selectedScopes, SCOPE_SHORT_NAMES); return (
{isAuthenticated && ( {(name || email || "?")[0]?.toUpperCase()} )}
{isAuthenticated ? name || email : "Sign in required"}
{isAuthenticated && name && email && (
{email}
)} {scopeSummary && (
{scopeSummary}
)}
); } /** * Airtable OAuth token data. * * Uses \`accessToken\` field (OAuth2TokenSchema convention). * * CRITICAL: When consuming from another pattern, DO NOT use derive()! * Use direct property access: \`airtableAuthPiece.auth\` */ export type AirtableAuth = { accessToken: Secret | Default<"">; tokenType: string | Default<"">; scope: string[] | Default<[]>; expiresIn: number | Default<0>; expiresAt: number | Default<0>; refreshToken: Secret | Default<"">; user: { email: string; name: string; picture: string; } | Default<{ email: ""; name: ""; picture: "" }>; }; // Selected scopes configuration export type SelectedScopes = { "data.records:read": boolean | Default; "data.records:write": boolean | Default; "data.recordComments:read": boolean | Default; "data.recordComments:write": boolean | Default; "schema.bases:read": boolean | Default; "schema.bases:write": boolean | Default; "webhook:manage": boolean | Default; }; interface Input { selectedScopes: SelectedScopes | Default<{ "data.records:read": true; "data.records:write": false; "data.recordComments:read": false; "data.recordComments:write": false; "schema.bases:read": true; "schema.bases:write": false; "webhook:manage": false; }>; auth: AirtableAuth | Default<{ accessToken: ""; tokenType: ""; scope: []; expiresIn: 0; expiresAt: 0; refreshToken: ""; user: { email: ""; name: ""; picture: "" }; }>; } /** Airtable OAuth authentication for Airtable APIs. #airtableAuth */ interface Output { auth: AirtableAuth; scopes: string[]; selectedScopes: SelectedScopes; /** Compact user display */ userChip: unknown; /** Minimal tile preview for picker/gallery display (CT-1321 variant) */ [TILE_UI]: unknown; /** Refresh the OAuth token from other pieces */ refreshToken: Stream>; /** Background updater for proactive token refresh */ bgUpdater: Stream>; } // Module-scope singleton refresh guard for Airtable OAuth. // This is intentional: all instances of this auth pattern share one guard, // preventing concurrent refresh requests. This is correct because each // provider (e.g. Airtable, Google) has its own module with its own guard. const refreshAuthToken = createRefreshFunction( "/api/integrations/airtable-oauth/refresh", ); // Handler for refreshing tokens from UI button const handleRefresh = handler< unknown, { auth: Writable; refreshing: Writable; refreshFailed: Writable; } >( async (_event, { auth, refreshing, refreshFailed }) => { refreshing.set(true); refreshFailed.set(false); try { const didRefresh = await refreshAuthToken(auth); refreshing.set(false); if (!didRefresh) return; refreshFailed.set(false); } catch { refreshing.set(false); refreshFailed.set(true); } }, ); // Handler for refreshing tokens from other pieces const refreshTokenHandler = handler< Record, { auth: Writable } >(async (_event, { auth }) => { await refreshAuthToken(auth); }); // Background updater handler for proactive token refresh const bgRefreshHandler = handler< Record, { auth: Writable } >( async (_event, { auth }) => { const currentAuth = auth.get(); if (!currentAuth?.accessToken || !currentAuth?.refreshToken) return; const expiresAt = currentAuth.expiresAt ?? 0; if (expiresAt <= 0) return; const timeRemaining = expiresAt - safeDateNow(); if (timeRemaining > REFRESH_THRESHOLD_MS) return; console.log( "[airtable-auth bgUpdater] Token expiring soon, refreshing...", ); try { await refreshAuthToken(auth); console.log("[airtable-auth bgUpdater] Token refreshed successfully"); } catch (e) { const status = (e as { status?: number }).status; const msg = e instanceof Error ? e.message : String(e); if (status === 400 || status === 401 || status === 403) { console.error( "[airtable-auth bgUpdater] Permanent refresh failure, clearing auth:", msg, ); auth.set({ accessToken: "", tokenType: "", scope: [], expiresIn: 0, expiresAt: 0, refreshToken: "", user: { email: "", name: "", picture: "" }, }); } else { console.error( "[airtable-auth bgUpdater] Transient refresh failure:", msg, ); } } }, ); export default pattern( ({ auth, selectedScopes }) => { // Compute active scopes based on selection. // Always include user.email:read so the whoami endpoint returns the email. const scopes = computed(() => { const base: string[] = ["user.email:read"]; for (const [key, enabled] of Object.entries(selectedScopes)) { if (enabled) { base.push(key); } } return base; }); const hasSelectedScopes = computed(() => Object.values(selectedScopes).some(Boolean) ); // Check if re-auth is needed (selected scopes differ from granted) const needsReauth = computed(() => { if (!auth?.accessToken) return false; const grantedScopes: string[] = auth?.scope || []; for (const [key, enabled] of Object.entries(selectedScopes)) { if (enabled && !grantedScopes.includes(key)) { return true; } } return false; }); const now = new Writable(safeDateNow()); startReactiveClock(now); const isTokenExpired = computed(() => { if (!auth?.accessToken || !auth?.expiresAt) return false; return auth.expiresAt < now.get(); }); const tokenExpiryDisplay = computed(() => formatTokenExpiry(auth?.expiresAt || 0, now.get()) ); const checkboxesDisabled = computed(() => !!auth?.accessToken); const refreshing = new Writable(false); const refreshFailed = new Writable(false); const scopesDisplay = computed(() => scopes.join(", ")); const hasEmail = computed(() => !!auth?.user?.email); const hasUserName = computed(() => !!auth?.user?.name); // Data-only computed for the tile preview — resolves reactive values to plain scalars const previewState = computed(() => { const email = auth?.user?.email || ""; const name = auth?.user?.name || ""; const isAuthenticated = !!email; const now = safeDateNow(); const expiresAt = auth?.expiresAt || 0; const isExpired = isAuthenticated && expiresAt > 0 && expiresAt < now; const isWarning = isAuthenticated && !isExpired && expiresAt > 0 && expiresAt - now < 10 * 60 * 1000; const status: AuthStatus = !isAuthenticated ? "needs-login" : isExpired ? "expired" : isWarning ? "warning" : "ready"; const scopeSummary = isAuthenticated ? getScopeSummary(auth?.scope || []) : getSelectedScopeSummary({ "data.records:read": !!selectedScopes["data.records:read"], "data.records:write": !!selectedScopes["data.records:write"], "data.recordComments:read": !!selectedScopes["data.recordComments:read"], "data.recordComments:write": !!selectedScopes["data.recordComments:write"], "schema.bases:read": !!selectedScopes["schema.bases:read"], "schema.bases:write": !!selectedScopes["schema.bases:write"], "webhook:manage": !!selectedScopes["webhook:manage"], }, SCOPE_SHORT_NAMES); const initial = (name || email || "?")[0]?.toUpperCase() || ""; const bgColor = STATUS_CONFIG[status].bg; const dotColor = STATUS_CONFIG[status].dot; return { email, name, isAuthenticated, bgColor, dotColor, scopeSummary, initial, }; }); const loggedIn = computed(() => !!auth?.accessToken); const notLoggedInWithScopes = computed(() => !loggedIn && hasSelectedScopes); const showTokenStatus = computed(() => !!auth?.user?.email && !isTokenExpired ); // Data-only computed for granted scopes const grantedScopesList = computed(() => { const scopeList: string[] = auth?.scope || []; return scopeList.map( (s: string) => SCOPE_MAP[s as keyof typeof SCOPE_MAP] || s, ); }); return { [NAME]: computed(() => { if (loggedIn) { return \`Airtable Auth (\${auth.user.email})\`; } return "Airtable Auth"; }), [UI]: (

Airtable Authentication

Status: {ifElse(loggedIn, "Authenticated", "Not Authenticated")}

{ifElse( loggedIn,

Email: {auth.user.email}

Name: {auth.user.name}

,

Select permissions below and authenticate with Airtable

, )}
{/* Permissions checkboxes */}

Permissions {ifElse( loggedIn, (locked while authenticated) , null, )}

{Object.entries(SCOPE_MAP).map(([key, description]) => ( ))}
{/* Re-auth warning */} {ifElse( needsReauth,
Note:{" "} You've selected new permissions. Click "Authenticate with Airtable" below to grant access.
, null, )} {/* Favorite reminder */} {ifElse( loggedIn,
Tip:{" "} Favorite this piece to share your Airtable auth across all your patterns. Any pattern using{" "} wish({"{"} query: "#airtableAuth" {"}"}){" "} will automatically find and use it.
, null, )} {/* Show selected scopes */} {ifElse( notLoggedInWithScopes,
Will request: {scopesDisplay}
, null, )} {/* Token expired warning */} {ifElse( isTokenExpired,

Session Expired

Your Airtable token has expired. Click below to refresh it.

{ifElse( refreshFailed,

Refresh failed — try signing in again below.

, null, )}
, null, )} {/* Show granted scopes */} {ifElse( loggedIn,
Granted Scopes:
    {grantedScopesList.map((scope) =>
  • {scope}
  • )}
, null, )} {/* Token status when authenticated and NOT expired */} {ifElse( showTokenStatus,

Token Status

Expires in: {tokenExpiryDisplay}

, null, )}
Usage:{" "} This piece provides Airtable OAuth authentication. Link its{" "} auth output to any Airtable importer piece's{" "} auth input, or favorite it for automatic discovery.
), auth, scopes, selectedScopes, userChip: ifElse( hasEmail,
{ifElse(hasUserName, auth.user.name, auth.user.email)}
{ifElse( hasUserName,
{auth.user.email}
, null, )}
,
Not signed in
, ), [TILE_UI]: (
{ifElse( previewState.isAuthenticated, {previewState.initial} , null, )}
{ifElse( previewState.isAuthenticated, {previewState.name || previewState.email}, Sign in required, )}
{ifElse( previewState.isAuthenticated && !!previewState.name && !!previewState.email,
{previewState.email}
, null, )} {ifElse( !!previewState.scopeSummary,
{previewState.scopeSummary}
, null, )}
), refreshToken: refreshTokenHandler({ auth }), bgUpdater: bgRefreshHandler({ auth }), }; }, ); `; // Read from: packages/patterns/airtable/core/util/airtable-auth-manager.tsx const AIRTABLE_AUTH_MANAGER_SOURCE = `/** * Airtable Auth Manager - Unified auth management utility * * Encapsulates Airtable Auth best practices: * - Uses wish() with framework picker for account selection * - Detects missing scopes and navigates to auth piece * - Detects expired tokens and provides recovery UI * - Pre-composed UI components for consistent UX * * Usage: * \`\`\`typescript * const { auth, fullUI, isReady } = AirtableAuthManager({ * requiredScopes: ["data.records:read", "schema.bases:read"], * }); * * if (!isReady) return; * // Use auth.accessToken for API calls * * return { [UI]:
{fullUI}
}; * \`\`\` */ import { action, navigateTo, pattern, Writable } from "commonfabric"; import { AuthManagerBase } from "../../../auth/create-auth-manager.tsx"; import type { AuthManagerDescriptor } from "../../../auth/auth-manager-descriptor.ts"; import AirtableAuth from "../airtable-auth.tsx"; // Re-export shared types for consumers export type { AuthInfo, AuthState, TokenExpiryWarning, } from "../../../auth/auth-types.ts"; export type { AuthManagerInput as AirtableAuthManagerInput, AuthManagerOutput as AirtableAuthManagerOutput, } from "../../../auth/create-auth-manager.tsx"; export type { AirtableAuth as AirtableAuthType } from "../airtable-auth.tsx"; /** Airtable scope keys */ export type ScopeKey = | "data.records:read" | "data.records:write" | "data.recordComments:read" | "data.recordComments:write" | "schema.bases:read" | "schema.bases:write" | "webhook:manage"; /** Human-readable scope descriptions */ const AIRTABLE_SCOPE_DESCRIPTIONS = { "data.records:read": "Read records", "data.records:write": "Write records", "data.recordComments:read": "Read record comments", "data.recordComments:write": "Write record comments", "schema.bases:read": "Read base schemas", "schema.bases:write": "Write base schemas", "webhook:manage": "Manage webhooks", } as const; export const SCOPE_DESCRIPTIONS: Record = AIRTABLE_SCOPE_DESCRIPTIONS; /** Unified scope registry for the auth manager base */ const SCOPES: AuthManagerDescriptor["scopes"] = Object.fromEntries( Object.entries(SCOPE_DESCRIPTIONS).map(([key, desc]) => [ key, { description: desc, scopeString: key }, ]), ); const AirtableAuthManagerDescriptor: AuthManagerDescriptor = { name: "airtable", displayName: "Airtable", brandColor: "#18BFFF", wishTag: "#airtableAuth", tokenField: "accessToken", scopes: SCOPES, hasAvatarSupport: false, }; export const AirtableAuthManager = pattern< import("../../../auth/create-auth-manager.tsx").AuthManagerInput, import("../../../auth/create-auth-manager.tsx").AuthManagerOutput >(({ requiredScopes, accountType, debugMode }) => { const createAuth = action(() => { const required = Array.isArray(requiredScopes) ? requiredScopes : []; const emptyAuth: Record = { tokenType: "", scope: [], expiresIn: 0, expiresAt: 0, refreshToken: "", user: { email: "", name: "", picture: "" }, accessToken: "", }; return navigateTo( AirtableAuth( { selectedScopes: { "data.records:read": new Writable( required.includes("data.records:read"), ), "data.records:write": new Writable( required.includes("data.records:write"), ), "data.recordComments:read": new Writable( required.includes("data.recordComments:read"), ), "data.recordComments:write": new Writable( required.includes("data.recordComments:write"), ), "schema.bases:read": new Writable( required.includes("schema.bases:read"), ), "schema.bases:write": new Writable( required.includes("schema.bases:write"), ), "webhook:manage": new Writable(required.includes("webhook:manage")), }, auth: emptyAuth, } as Parameters[0], ), ); }); return AuthManagerBase({ requiredScopes, accountType, debugMode, descriptor: AirtableAuthManagerDescriptor, createAuth, }); }); export default AirtableAuthManager; `; // Read from: packages/patterns/airtable/core/util/airtable-client.ts const AIRTABLE_CLIENT_SOURCE = `/** * Airtable API client with automatic token refresh and retry logic. * * Usage: * \\\`\\\`\\\`typescript * import { AirtableClient } from "./util/airtable-client.ts"; * * const client = AirtableClient(authCell, { debugMode: true }); * const bases = await client.listBases(); * const tables = await client.listTables(baseId); * const records = await client.listRecords(baseId, tableId); * \\\`\\\`\\\` */ import { getPatternEnvironment, Writable } from "commonfabric"; import type { AirtableAuth as AirtableAuthType } from "../airtable-auth.tsx"; // ============================================================================ // TYPES // ============================================================================ export interface AirtableClientConfig { retries?: number; delay?: number; debugMode?: boolean; /** External refresh callback for cross-piece token refresh */ onRefresh?: () => Promise; } export interface AirtableBase { id: string; name: string; permissionLevel: string; } export interface AirtableTable { id: string; name: string; description?: string; primaryFieldId: string; fields: AirtableField[]; } export interface AirtableField { id: string; name: string; type: string; description?: string; options?: Record; } export interface AirtableRecord { id: string; createdTime: string; fields: Record; } export interface ListRecordsOptions { pageSize?: number; maxRecords?: number; view?: string; filterByFormula?: string; sort?: Array<{ field: string; direction?: "asc" | "desc" }>; fields?: string[]; } // ============================================================================ // HELPERS // ============================================================================ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function debugLog(debugMode: boolean, ...args: unknown[]) { if (debugMode) console.log("[AirtableClient]", ...args); } // ============================================================================ // CLIENT // ============================================================================ const AIRTABLE_API_BASE = "https://api.airtable.com/v0"; const AIRTABLE_META_BASE = "https://api.airtable.com/v0/meta"; export interface AirtableClient { listBases(): Promise; listTables(baseId: string): Promise; listRecords( baseId: string, tableIdOrName: string, options?: ListRecordsOptions, ): Promise; } export function AirtableClient( authCell: Writable, config: AirtableClientConfig = {}, ): AirtableClient { const retries = config.retries ?? 2; const delay = config.delay ?? 1000; const debugMode = config.debugMode ?? false; const onRefresh = config.onRefresh; function getToken(): string { const auth = authCell.get(); return auth?.accessToken || ""; } /** * Make an authenticated API request with retry and token refresh. */ async function request( url: string, options: RequestInit = {}, ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt <= retries; attempt++) { const token = getToken(); if (!token) { throw new Error("No access token available"); } try { const response = await fetch(url, { ...options, headers: { Authorization: \\\`Bearer \\\${token}\\\`, "Content-Type": "application/json", ...options.headers, }, }); if (response.status === 401) { debugLog(debugMode, "Got 401, attempting token refresh..."); await refreshToken(); continue; } if (response.status === 429) { const retryAfter = response.headers.get("Retry-After"); const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : delay * (attempt + 1); debugLog( debugMode, \\\`Rate limited, waiting \\\${waitMs}ms...\\\`, ); await sleep(waitMs); continue; } if (!response.ok) { const errorBody = await response.text(); throw new Error( \\\`Airtable API error \\\${response.status}: \\\${errorBody}\\\`, ); } return (await response.json()) as T; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < retries) { debugLog( debugMode, \\\`Request failed (attempt \\\${attempt + 1}/\\\${retries + 1}):\\\`, lastError.message, ); await sleep(delay); } } } throw lastError || new Error("Request failed after retries"); } /** * Refresh the access token via the server endpoint. */ async function refreshToken(): Promise { if (onRefresh) { await onRefresh(); return; } const auth = authCell.get(); const refreshToken = auth?.refreshToken; if (!refreshToken) { throw new Error("No refresh token available"); } const env = getPatternEnvironment(); const res = await fetch( new URL("/api/integrations/airtable-oauth/refresh", env.apiUrl), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }, ); if (!res.ok) { throw new Error(\\\`Token refresh failed: \\\${res.status}\\\`); } const json = await res.json(); if (!json.tokenInfo) { throw new Error("Invalid refresh response"); } authCell.update({ ...json.tokenInfo, user: auth.user, }); debugLog(debugMode, "Token refreshed successfully"); } // ========================================================================== // API METHODS // ========================================================================== /** * List all accessible bases. */ async function listBases(): Promise { debugLog(debugMode, "Listing bases..."); const bases: AirtableBase[] = []; let offset: string | undefined; do { const url = new URL(\\\`\\\${AIRTABLE_META_BASE}/bases\\\`); if (offset) url.searchParams.set("offset", offset); const response = await request<{ bases: AirtableBase[]; offset?: string; }>(url.toString()); bases.push(...response.bases); offset = response.offset; } while (offset); debugLog(debugMode, \\\`Found \\\${bases.length} bases\\\`); return bases; } /** * List all tables in a base. */ async function listTables( baseId: string, ): Promise { debugLog(debugMode, \\\`Listing tables for base \\\${baseId}...\\\`); const response = await request<{ tables: AirtableTable[] }>( \\\`\\\${AIRTABLE_META_BASE}/bases/\\\${baseId}/tables\\\`, ); debugLog(debugMode, \\\`Found \\\${response.tables.length} tables\\\`); return response.tables; } /** * List records from a table with pagination. */ async function listRecords( baseId: string, tableIdOrName: string, options: ListRecordsOptions = {}, ): Promise { debugLog( debugMode, \\\`Listing records from \\\${baseId}/\\\${tableIdOrName}...\\\`, ); const records: AirtableRecord[] = []; let offset: string | undefined; const maxRecords = options.maxRecords ?? 1000; do { const url = new URL( \\\`\\\${AIRTABLE_API_BASE}/\\\${baseId}/\\\${encodeURIComponent(tableIdOrName)}\\\`, ); if (options.pageSize) { url.searchParams.set( "pageSize", String(Math.min(options.pageSize, 100)), ); } if (offset) url.searchParams.set("offset", offset); if (options.view) url.searchParams.set("view", options.view); if (options.filterByFormula) { url.searchParams.set("filterByFormula", options.filterByFormula); } if (options.fields) { for (const field of options.fields) { url.searchParams.append("fields[]", field); } } if (options.sort) { for (let i = 0; i < options.sort.length; i++) { url.searchParams.set(\\\`sort[\\\${i}][field]\\\`, options.sort[i].field); if (options.sort[i].direction) { url.searchParams.set( \\\`sort[\\\${i}][direction]\\\`, options.sort[i].direction!, ); } } } const response = await request<{ records: AirtableRecord[]; offset?: string; }>(url.toString()); records.push(...response.records); offset = response.offset; if (records.length >= maxRecords) { break; } } while (offset); const result = records.slice(0, maxRecords); debugLog(debugMode, \\\`Fetched \\\${result.length} records\\\`); return result; } return { listBases, listTables, listRecords }; } `; // Read from: packages/patterns/airtable/airtable-importer.tsx const AIRTABLE_IMPORTER_SOURCE = `import { computed, Default, handler, ifElse, NAME, pattern, UI, Writable, } from "commonfabric"; import { AirtableAuthManager, type ScopeKey, } from "./core/util/airtable-auth-manager.tsx"; import { AirtableClient } from "./core/util/airtable-client.ts"; import type { AirtableAuth } from "./core/airtable-auth.tsx"; // ============================================================================ // TYPES // ============================================================================ /** An Airtable record with its fields */ type AirtableRecordData = { id: string; createdTime: string; fields: Record; }; type BaseInfo = { id: string; name: string }; type TableInfo = { id: string; name: string }; interface Input { selectedBaseId: string | Default<"">; selectedTableId: string | Default<"">; } /** Import records from an Airtable base. #airtableImporter */ interface Output { records: readonly AirtableRecordData[]; bases: readonly BaseInfo[]; tables: readonly TableInfo[]; selectedBaseId: string; selectedTableId: string; selectedBaseName: string; selectedTableName: string; recordCount: number; } // ============================================================================ // REQUIRED SCOPES // ============================================================================ const REQUIRED_SCOPES: ScopeKey[] = [ "data.records:read", "schema.bases:read", ]; // ============================================================================ // MODULE-SCOPE HANDLERS // ============================================================================ const fetchBases = handler< unknown, { auth: Writable; bases: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, bases, loading, error }) => { loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listBases(); bases.set(result.map((b) => ({ id: b.id, name: b.name }))); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const fetchTables = handler< unknown, { auth: Writable; baseId: string; tables: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, baseId, tables, loading, error }) => { if (!baseId) return; loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listTables(baseId); tables.set(result.map((t) => ({ id: t.id, name: t.name }))); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const fetchRecords = handler< unknown, { auth: Writable; baseId: string; tableId: string; records: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, baseId, tableId, records, loading, error }) => { if (!baseId || !tableId) return; loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listRecords(baseId, tableId, { maxRecords: 500, }); records.set( result.map((r) => ({ id: r.id, createdTime: r.createdTime, fields: r.fields, })), ); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const onSelectBase = handler< { target: { dataset: { baseId: string } } }, { selectedBaseId: Writable; selectedTableId: Writable; tables: Writable; records: Writable; } >((event, { selectedBaseId, selectedTableId, tables, records }) => { const baseId = event.target.dataset.baseId; if (!baseId) return; selectedBaseId.set(baseId); selectedTableId.set(""); tables.set([]); records.set([]); }); const onSelectTable = handler< { target: { dataset: { tableId: string } } }, { selectedTableId: Writable; records: Writable; } >((event, { selectedTableId, records }) => { const tableId = event.target.dataset.tableId; if (!tableId) return; selectedTableId.set(tableId); records.set([]); }); // ============================================================================ // PATTERN // ============================================================================ export default pattern( ({ selectedBaseId, selectedTableId }) => { // Auth manager const { auth: authResult, isReady, fullUI: authUI, } = AirtableAuthManager({ requiredScopes: REQUIRED_SCOPES, }); const auth = authResult as any; // State const bases = new Writable([]); const tables = new Writable([]); const records = new Writable([]); const loading = new Writable(false); const error = new Writable(""); const hasBases = computed(() => bases.get().length > 0); const hasTables = computed(() => tables.get().length > 0); const hasRecords = computed( () => records.get().length > 0, ); const recordCount = computed( () => records.get().length, ); const selectedBaseName = computed(() => { if (!selectedBaseId) return ""; const base = bases.get().find( (b) => b.id === selectedBaseId, ); return base?.name || ""; }); const selectedTableName = computed(() => { if (!selectedTableId) return ""; const table = tables.get().find( (t) => t.id === selectedTableId, ); return table?.name || ""; }); // Bound handlers -- pass reactive inputs directly (no double-cast) const boundFetchBases = fetchBases({ auth, bases, loading, error }); const boundFetchTables = fetchTables({ auth, baseId: selectedBaseId, tables, loading, error, }); const boundFetchRecords = fetchRecords({ auth, baseId: selectedBaseId, tableId: selectedTableId, records, loading, error, }); const boundSelectBase = onSelectBase({ selectedBaseId, selectedTableId, tables, records, }); const boundSelectTable = onSelectTable({ selectedTableId, records, }); // Column headers extracted from records const columnHeaders = computed(() => { const recs = records.get(); if (recs.length === 0) return [] as string[]; const allKeys = new Set(); for (const rec of recs.slice(0, 10)) { for (const key of Object.keys(rec.fields)) { allKeys.add(key); } } return Array.from(allKeys); }); const hasBaseSelected = computed(() => !!selectedBaseId); const hasTableSelected = computed(() => !!selectedTableId); // Pre-compute base/table lists for JSX const baseListUI = computed(() => bases.get().map((base) => ( )) ); const tableListUI = computed(() => tables.get().map((table) => ( )) ); // Precompute table rows as plain data const tableRows = computed(() => { const recs = records.get(); const hdrs = columnHeaders; return recs.map((rec) => ({ cells: hdrs.map((col) => formatCellValue(rec.fields[col])), })); }); const hasError = computed(() => !!error.get()); return { [NAME]: computed(() => { if (selectedBaseName && selectedTableName) { return \\\`Airtable: \\\${selectedBaseName} / \\\${selectedTableName}\\\`; } return "Airtable Importer"; }), [UI]: (

Airtable Importer

{/* Auth section */} {authUI} {/* Main content - only when authenticated */} {ifElse( isReady,
{/* Base selection */}

Select a Base

{ifElse( hasBases,
{baseListUI}
,

Click "Load Bases" to see your Airtable bases.

, )}
{/* Table selection */} {ifElse( hasBaseSelected,

Select a Table from {selectedBaseName}

{ifElse( hasTables,
{tableListUI}
,

Click "Load Tables" to see tables in this base.

, )}
, null, )} {/* Fetch records */} {ifElse( hasTableSelected,

Records from {selectedTableName}

{ifElse( hasRecords,

{recordCount} records loaded

{columnHeaders.map((col) => ( ))} {tableRows.map( (row) => ( {row.cells.map((cell) => ( ))} ), )}
{col}
{cell}
,

Click "Fetch Records" to load data from this table.

, )}
, null, )} {/* Error display */} {ifElse( hasError,
Error: {error}
, null, )}
, null, )}
), records: computed(() => records.get()), bases: computed(() => bases.get()), tables: computed(() => tables.get()), selectedBaseId, selectedTableId, selectedBaseName, selectedTableName, recordCount, }; }, ); // ============================================================================ // HELPERS // ============================================================================ function formatCellValue(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { return value.map((v) => formatCellValue(v)).join(", "); } if (typeof value === "object") { return JSON.stringify(value); } return String(value); }`; // --------------------------------------------------------------------------- // Prompt generation // --------------------------------------------------------------------------- /** * Generate a comprehensive prompt for Claude to produce a complete importer * pattern suite for the given API provider. */ export function generateImporterPrompt(ctx: PromptContext): string { const { providerName, brandColor, api, providerConfig, primaryListEndpoint, primaryGetEndpoint, } = ctx; const pascalName = toPascalCase(providerName); const camelName = pascalName.charAt(0).toLowerCase() + pascalName.slice(1); const hashTag = `#${camelName}Auth`; const providerLabel = pascalName; const sections: string[] = []; // ========================================================================= // SECTION 1: System context — Pattern framework overview // ========================================================================= sections.push(` You are generating Common Fabric pattern files for the "${providerLabel}" API integration. ## Common Fabric Pattern Framework Common Fabric patterns are reactive programs (similar to Solid.js components) that define a reactive graph once upfront. They are NOT re-invoked like React components. ### Imports All patterns start with: \`\`\`tsx import { computed, Default, handler, ifElse, NAME, pattern, Stream, UI, Writable, getPatternEnvironment, wish, action, navigateTo, safeDateNow, nonPrivateRandom, TILE_UI, CHIP_UI, uiVariant, } from "commonfabric"; // Local no-op type alias for marking sensitive fields type Secret = T; \`\`\` Import only what you need from the above list. Define \`type Secret = T;\` locally when you need to mark fields as sensitive. ### Core Concepts - **\`pattern(fn)\`** — Defines a pattern. The function runs once and returns an object with output cells. - **\`computed(() => expr)\`** — Derived reactive value. Re-evaluates when dependencies change. NEVER access \`wishResult[UI]\` inside a computed. - **\`new Writable(initialValue)\`** — Mutable reactive cell. Use \`.get()\` to read, \`.set(value)\` to write, \`.update(partial)\` for partial updates. - **\`handler(async (event, context) => { ... })\`** — Async event handler. Declare at module scope, bind inside the pattern by passing context: \`myHandler({ cell1, cell2 })\`. - **\`ifElse(condition, trueNode, falseNode)\`** — Conditional rendering. condition must be a reactive value (computed or cell). - **\`wish({ query, scope })\`** — Discover pieces across the space. Returns \`{ result, [UI] }\`. The \`[UI]\` is a picker component. NEVER access \`wishResult[UI]\` inside a \`computed()\` — it crashes the reactive graph. Scope values: \`"."\` means the current space, \`"~"\` means the user's home space. - **\`action(() => expr)\`** — Create an inline handler inside the pattern body that closes over local variables (e.g. for navigation side-effects). Use \`handler()\` instead for module-scope handlers that receive context via binding. - **\`navigateTo(piece)\`** — Navigate to another piece. - **\`[NAME]\`** — Special symbol for the piece's display name. - **\`[UI]\`** — Special symbol for the piece's rendered UI. - **\`T | Default\`** — Type with a default value. For mutable arrays in schemas, the standard pattern is \`Writable>\`. - **\`T | DeepDefault\`** — Object type with a partial recursive default. Use this when the default lists only some nested object properties. - **\`Secret\`** — Local no-op type alias (\`type Secret = T;\`) for marking sensitive fields. - **\`Stream\`** — Stateless channel. Written via \`.send()\`. Used for handlers that can be called from other pieces. ### UI Components Use \`cf-*\` custom elements: - \`\` — OAuth flow component - \`Label\` — Checkbox with bidirectional binding - \`\` — Text input with bidirectional binding - \`\` — Select dropdown - \`Label\` — Button - \`...\` — Styled card container - \`...\` — Vertical stack layout - \`\` — Render a sub-pattern Native HTML elements (\`
\`, \`\`, \`
\` with sticky headers - Error display with \`ifElse(hasError, errorDiv, null)\` 5. Use brand color \`${brandColor}\` for buttons and highlights ## Critical Patterns to Follow 1. **wish() for auth discovery** — Always use \`wish({ query: "${hashTag}", scope: [".", "~"] })\` 2. **handler() for async ops** — Define at module scope, bind inside pattern 3. **ifElse() for conditional rendering** — condition must be computed/cell, not raw boolean 4. **new Writable() for mutable state** — Use \`.get()\` in handlers, \`.set()\` to update 5. **computed() for derived values** — Pure computations only, no side effects 6. **Token refresh on 401** — Client auto-refreshes via server endpoint 7. **No React patterns** — No useState, useEffect, hooks, or re-rendering 8. **Data in
** — Use standard HTML table with inline styles for data display 9. **CTS transforms are enabled by default** — Do not add \`/// \` unless you are intentionally opting out 10. **Import from "commonfabric"** — Not from individual packages `); return sections.join("\n\n"); }