// ---------------------------------------------------------------------------// @executor-js/sdk/client — frontend half of the plugin SDK.//// Plugins import from this entry to register pages/widgets and consume// their own typed reactive client. Server bundles must NOT import this// module — it pulls in React + @effect/atom-react. Plugin packages should// keep React/atom imports inside `./client.tsx` and Effect/Node imports// inside `./server.ts`; shared schema definitions go in `./shared.ts` and// can be imported from both halves.// --------------------------------------------------------------------------- import { createContext, createElement, useContext, useEffect, useMemo, type ComponentType, type ReactNode,} from "react";import { HttpApi } from "effect/unstable/httpapi";import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";import type { HealthCheckSpec } from "./health-check"; // ---------------------------------------------------------------------------// Re-exports — the curated set of primitives a plugin author needs to// build a typed reactive UI without reaching into `effect/*` directly.// --------------------------------------------------------------------------- export { Schema } from "effect";export { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; export * as AsyncResult from "effect/unstable/reactivity/AsyncResult";export * as Atom from "effect/unstable/reactivity/Atom";export * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/atom-react"; // ---------------------------------------------------------------------------// defineClientPlugin — declarative spec for the frontend half of a plugin.//// Mirror of `definePlugin` on the server, but everything here is React /// browser-only. The host treats the value as data: collects routes,// widgets, and slot components from every loaded plugin and mounts them// alongside the host's own UI.// --------------------------------------------------------------------------- export interface PluginPageProps { /** Plugin-relative route params captured from `PageDecl.path` segments. */ readonly params: Readonly<Record<string, string>>; /** The normalized plugin-relative URL path that matched this page. */ readonly path: string; /** The plugin id from `/plugins/$pluginId/...`. */ readonly pluginId: string;} export interface PageDecl { /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ readonly path: string; readonly component: ComponentType<PluginPageProps>; /** Optional sidebar nav metadata — the host renders these alongside its * own nav links. Omit to register a page without a nav entry. */ readonly nav?: { readonly label: string; readonly section?: string; };} export interface WidgetProps { readonly scopeId?: string;} export interface WidgetDecl { readonly id: string; readonly component: ComponentType<WidgetProps>; readonly size?: "half" | "full";} /** * Open record of host-defined slot components a plugin can fill. Slot * names are part of the host UI contract — plugins opt in by registering * a component for the slot they care about. Adding a slot is a host-side * change; plugin authors don't define new slots. */export type SlotComponent = ComponentType<Record<string, unknown>>; // ---------------------------------------------------------------------------// IntegrationPlugin / IntegrationPreset — UI contract for plugins that expose// "integrations" (OpenAPI specs, MCP servers, GraphQL endpoints, etc.). The// host owns the integration list / detail chrome; the plugin owns the// add-flow, edit form, and (optional) summary + sign-in buttons.//// Lives here, not in `@executor-js/react`, so it's part of the plugin// contract: a plugin's `./client` entry assembles its `integrationPlugin`// alongside `pages`/`widgets`, and the host derives the union list// from `virtual:executor/plugins-client`.// --------------------------------------------------------------------------- export interface IntegrationPreset { /** Unique id (e.g. "stripe", "github-graphql"). */ readonly id: string; readonly name: string; readonly summary: string; /** URL passed as `initialUrl` to the add form. Omit for presets that * don't use a URL (e.g. stdio MCP presets). */ readonly url?: string; /** Endpoint passed to agent-facing probe/add tools when their schema * uses `endpoint` instead of `url`. */ readonly endpoint?: string; /** Optional icon URL (favicon, logo). */ readonly icon?: string; /** Image to show when `icon` cannot be resolved on this machine — a preset * whose icon is read from a local install has none until that install * exists, which is exactly when the card most needs to identify itself. */ readonly fallbackIcon?: string; /** Shown in the top-level grid on the integrations page when true. */ readonly featured?: boolean; readonly family?: string; readonly specFormat?: string; readonly defaultSlug?: string; /** Plugin-specific RFC 6902 operations applied to a fetched specification. */ readonly specOverrides?: readonly unknown[]; readonly authTemplate?: readonly IntegrationPresetAuthentication[]; readonly healthCheck?: HealthCheckSpec; /** The public registry lists this product, so the picker shows the * registry's card instead of a preset card (the preset's knowledge still * rides quick add). Built-in presets set this; a deployment's custom * preset does not, and keeps its own card — hiding it would make a * private API undiscoverable. */ readonly registryListed?: boolean;} export type IntegrationPresetAuthentication = | { readonly slug: string; readonly kind: "oauth2"; readonly label?: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly scopes: readonly string[]; readonly supportsClientIdMetadataDocument?: boolean; } | { readonly kind: "apiKey"; readonly slug?: string; readonly name?: string; readonly placements?: readonly unknown[]; readonly headers?: Readonly<Record<string, readonly unknown[]>>; readonly queryParams?: Readonly<Record<string, readonly unknown[]>>; readonly cookies?: Readonly<Record<string, readonly unknown[]>>; }; export interface IntegrationAccountHandoff { /** Changes on each handoff URL, so the accounts UI can open once per link. */ readonly key: string; readonly owner?: "org" | "user"; /** Auth template/method to preselect when present. */ readonly template?: string; /** Non-secret connection label to prefill. */ readonly label?: string; /** Existing display identity to preserve when reconnecting a saved row. */ readonly identityLabel?: string; /** Present when the agent handed off a CONFIDENTIAL OAuth-app registration * (via `oauth.clients.createHandoff`) or when a saved OAuth connection is * reconnecting through its stored app. Registration opens the * Register-OAuth-app form pre-filled with these NON-secret fields, and the * human types the client secret directly into the browser. Reconnect starts * OAuth with the existing public client. */ readonly oauthClient?: { readonly action?: "register" | "reconnect"; /** Preselected client slug; when set the form's slug is fixed. */ readonly slug?: string; readonly owner?: "org" | "user"; readonly grant?: string; readonly clientId?: string; readonly authorizationUrl?: string; readonly tokenUrl?: string; /** RFC 8707 resource indicator. On a reconnect handoff this is the STORED * client's value, and an EXPLICIT null means the stored client was * registered WITHOUT a resource indicator — that absence must survive a * re-registration (some servers reject any `resource` parameter). * Undefined means no stored value was carried. */ readonly resource?: string | null; /** Reconnect only: the stored client binding is an auto-minted DCR client * (or its row is gone), so the modal may re-run the automatic * probe/registration flow. Absent or false pins the reconnect to the * stored client — a static/BYO or first-party binding must never be * silently rebound to an automatic client. */ readonly dynamicRegistration?: boolean; };} /** Outcome of applying an edit-sheet section's staged change. `summary` is * toasted on success; `ok: false` keeps the sheet open (the section renders * its own error inline). */export type EditSheetApplyResult = | { readonly ok: true; readonly summary: string | null } | { readonly ok: false }; export interface EditSheetSectionProps { readonly integrationId: string; readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;} /** What a registry row already knows about a connect target — enough, for * some plugins, to register without showing the configuration screen. */export interface IntegrationQuickAddInput { /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ readonly url: string; /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ readonly name: string; /** Registry surface slug, used as the namespace seed when present. */ readonly slug?: string; /** The registry PRODUCT's domain (notion.com) — the identity credential * guidance and favicons key on. Often differs from the connect URL's * host: specs live on code hosts. */ readonly domain?: string; /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ readonly authHeader?: string; /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ readonly authKind?: string; /** RFC 6902 patch the registry says to apply to the fetched spec. */ readonly specOverrides?: readonly unknown[];} export type IntegrationQuickAddResult = /** Registered; `slug` is the created integration's namespace. */ | { readonly ok: true; readonly slug: string } /** Could not add headlessly — the host falls back to the configuration * screen, which renders the failure with full context. */ | { readonly ok: false; readonly reason: string }; export interface IntegrationPlugin { /** Unique key matching the SDK plugin id (e.g. "openapi"). */ readonly key: string; readonly label: string; readonly add: ComponentType<{ /** Called when the integration has been registered. Receives the slug of * the just-registered integration, so the host can route to its detail * hub (`/integrations/<slug>`). Optional so existing no-arg calls still * typecheck while plugins are threading the slug through. */ readonly onComplete: (slug?: string) => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string; readonly initialNamespace?: string; /** Registry-declared credential placement for the surface, e.g. * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) * seed their auth-method editor from it. */ readonly initialAuthHeader?: string; readonly initialAuthNote?: string; /** Registry-declared credential kind ("none", "oauth", "api_key", …). * Lets a flow whose live probe fails, or whose surface can't be probed, * still declare the right method — an authless MCP server is a fact the * registry already knows. */ readonly initialAuthKind?: string; /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched * spec — the registry's mechanism for improving a vendor's published * document over time without hosting a fork. */ readonly initialSpecOverrides?: string; }>; /** Legacy full-page edit surface. No host renders this anymore — plugin * configuration lives in the integration Edit sheet via `editSheet`. */ readonly edit?: ComponentType<{ readonly integrationId: string; readonly onSave: () => void; }>; /** Plugin-owned configuration rendered inside the integration's Edit sheet, * below the shared metadata fields (e.g. the OpenAPI spec-update controls). * The sheet has ONE Save: the section stages its pending change locally and * reports it through `onPendingChange` — a thunk that applies the staged * change. Save runs the metadata update, then the staged apply; a failed * apply keeps the sheet open with the section showing its own error. */ readonly editSheet?: ComponentType<EditSheetSectionProps>; readonly summary?: ComponentType<{ readonly integrationId: string; readonly variant?: "badge" | "panel"; readonly onAction?: () => void; }>; /** Renders the integration's Accounts hub (auth methods + connections) inside * the detail page's Accounts tab. Plugins that declare auth methods implement * this; the page falls back to a generic accounts list when absent. */ readonly accounts?: ComponentType<{ readonly integrationId: string; readonly integrationName: string; readonly accountHandoff?: IntegrationAccountHandoff | null; }>; readonly presets?: readonly IntegrationPreset[]; /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). * Call from the host on intent (hover/focus) so the chunks land before the * user navigates into the add page. Idempotent. */ readonly preload?: () => void; /** Headless one-click add for a registry row whose facts are already known * (URL + auth indicators). A HOOK, not a plain function, because each * plugin binds its own mutation atoms — the host mounts one bridge * component per plugin to collect the bound callbacks. Absent means the * plugin always needs its configuration screen. A `{ok: false}` result is * an expected outcome (unreachable server, slug collision), not an error: * the host falls back to the configuration screen prefilled with the same * facts. */ readonly useQuickAdd?: () => ( input: IntegrationQuickAddInput, ) => Promise<IntegrationQuickAddResult>;} // ---------------------------------------------------------------------------// SecretProviderPlugin — UI contract for plugins that contribute secret// providers (1Password, WorkOS Vault, etc.). The host owns the secrets// page chrome; the plugin owns the settings card rendered inside.// --------------------------------------------------------------------------- export interface SecretProviderPlugin { /** Unique key matching the SDK plugin id (e.g. "onepassword"). */ readonly key: string; readonly label: string; readonly settings: ComponentType<Record<string, never>>;} export interface ClientPluginSpec<TId extends string = string> { readonly id: TId; readonly pages?: readonly PageDecl[]; readonly widgets?: readonly WidgetDecl[]; readonly slots?: Record<string, SlotComponent>; /** Integration plugin contribution — populated by plugins that expose * `kind` rows in the core `integration` table (openapi, mcp, graphql). * The host's integrations page derives its provider * list from the union of every loaded plugin's `integrationPlugin`. */ readonly integrationPlugin?: IntegrationPlugin; /** Secret provider plugin contribution — populated by plugins that * also ship a `secretProviders` (or related) server-side capability * AND want to expose a settings card on the host's secrets page. */ readonly secretProviderPlugin?: SecretProviderPlugin;} /** * Identity factory — returns the spec unchanged but pins the inferred * literal type of `id` so the host can index plugin records by id with * full autocomplete. Plugins export this as their package's default * (or named) export from `./client`. */export const defineClientPlugin = <const TId extends string>( spec: ClientPluginSpec<TId>,): ClientPluginSpec<TId> => spec; // ---------------------------------------------------------------------------// createPluginAtomClient — typed reactive HTTP client for one plugin.//// Wraps the plugin's `HttpApiGroup` in a per-plugin `HttpApi`, then// hands back an `AtomHttpApi.Service` keyed to that bundle. The// resulting service exposes `.query("group", "endpoint", opts)` and// `.mutation("group", "endpoint")` factories — same shape as the host's// existing `ExecutorApiClient` (see packages/react/src/api/client.tsx).// Per-endpoint payload/response/error types flow through from the// imported group, so plugin client code typechecks without codegen.//// The plugin id (used for the Service Tag and the synthetic API id) is// read from `group.identifier` — the same string the plugin passed to// `HttpApiGroup.make("foo")`. No second-source duplication.// --------------------------------------------------------------------------- export interface CreatePluginAtomClientOptions { /** Override the base URL. Defaults to `/api` (host strips this prefix * when forwarding to the Effect handler) — same convention as the * core `ExecutorApiClient`. */ readonly baseUrl?: string | (() => string); /** Optional dynamic Authorization header for hosts whose active * Executor Server Connection requires Basic or Bearer auth. */ readonly authorizationHeader?: string | null | (() => string | null); /** Optional dynamic request headers supplied by the host shell. */ readonly headers?: | Readonly<Record<string, string | null | undefined>> | (() => Readonly<Record<string, string | null | undefined>>);} export interface PluginAtomClientRequestTransformOptions { readonly baseUrl?: () => string; readonly authorizationHeader?: string | null | (() => string | null); readonly headers?: | Readonly<Record<string, string | null | undefined>> | (() => Readonly<Record<string, string | null | undefined>>);} /** @internal */export const applyPluginAtomClientRequestTransform = ( request: HttpClientRequest.HttpClientRequest, options: PluginAtomClientRequestTransformOptions,): HttpClientRequest.HttpClientRequest => { let next = options.baseUrl ? HttpClientRequest.prependUrl(request, options.baseUrl()) : request; const authorization = typeof options.authorizationHeader === "function" ? options.authorizationHeader() : options.authorizationHeader; if (authorization) { next = HttpClientRequest.setHeader(next, "authorization", authorization); } const headers = typeof options.headers === "function" ? options.headers() : options.headers; if (headers) { for (const [name, value] of Object.entries(headers)) { if (value !== undefined && value !== null) { next = HttpClientRequest.setHeader(next, name, value); } } } return next;}; /** * Build a typed reactive client for a plugin's HttpApiGroup. * * const FooClient = createPluginAtomClient(FooApi) * export const fooThings = FooClient.query("foo", "listThings", { ... }) * export const fooSync = FooClient.mutation("foo", "syncThing") * * Each plugin gets a private service Tag (`Plugin_<id>Client`) keyed by * the group's `identifier`, so multiple plugins coexist in the same * React tree without colliding. */export const createPluginAtomClient = < G extends HttpApiGroup.HttpApiGroup<string, HttpApiEndpoint.Any, boolean>,>( group: G, options: CreatePluginAtomClientOptions = {},) => { const { baseUrl = "/api", authorizationHeader, headers } = options; const pluginId = group.identifier; const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; const staticBaseUrl = typeof baseUrl === "function" ? undefined : baseUrl; const getAuthorizationHeader = typeof authorizationHeader === "function" ? authorizationHeader : null; const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; const hasHeaders = headers !== undefined; const transformClient = getBaseUrl || hasAuthorization || hasHeaders ? HttpClient.mapRequest((request) => applyPluginAtomClientRequestTransform(request, { ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), ...(getAuthorizationHeader ? { authorizationHeader: getAuthorizationHeader } : authorizationHeader !== undefined ? { authorizationHeader } : {}), ...(headers !== undefined ? { headers } : {}), }), ) : undefined; return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, { api: bundle, httpClient: FetchHttpClient.layer, ...(staticBaseUrl !== undefined ? { baseUrl: staticBaseUrl } : {}), ...(transformClient ? { transformClient } : {}), });}; // ---------------------------------------------------------------------------// ExecutorPluginsProvider + hooks — host-level distribution of the loaded// `ClientPluginSpec[]` via React context.//// The host wraps once at the root of its tree (typically reading from// `virtual:executor/plugins-client`); pages and shared components consume// via the focused hooks (`useIntegrationPlugins` etc.) so they don't import// from any host-app aggregator file. Pages stay portable across hosts —// the same component renders against whatever plugin set the surrounding// `<ExecutorPluginsProvider>` provides.//// Hooks throw if no provider is in scope so missing setup fails loudly;// matches the pattern of `useScope` / `useAuth` already in the codebase.// --------------------------------------------------------------------------- interface ExecutorPluginsContextValue { readonly plugins: readonly ClientPluginSpec[]; readonly integrationPlugins: readonly IntegrationPlugin[]; readonly secretProviderPlugins: readonly SecretProviderPlugin[];} const ExecutorPluginsContext = createContext<ExecutorPluginsContextValue | null>(null);ExecutorPluginsContext.displayName = "ExecutorPluginsContext"; export interface ExecutorPluginsProviderProps { readonly plugins: readonly ClientPluginSpec[]; readonly children: ReactNode;} export function ExecutorPluginsProvider( props: ExecutorPluginsProviderProps,): ReturnType<typeof createElement> { const { plugins, children } = props; const value = useMemo<ExecutorPluginsContextValue>( () => ({ plugins, integrationPlugins: plugins.flatMap((p) => p.integrationPlugin ? [p.integrationPlugin] : [], ), secretProviderPlugins: plugins.flatMap((p) => p.secretProviderPlugin ? [p.secretProviderPlugin] : [],Lines 1–500 of 532. Load more
// --------------------------------------------------------------------------- // @executor-js/sdk/client — frontend half of the plugin SDK. // // Plugins import from this entry to register pages/widgets and consume // their own typed reactive client. Server bundles must NOT import this // module — it pulls in React + @effect/atom-react. Plugin packages should // keep React/atom imports inside `./client.tsx` and Effect/Node imports // inside `./server.ts`; shared schema definitions go in `./shared.ts` and // can be imported from both halves. // --------------------------------------------------------------------------- import { createContext, createElement, useContext, useEffect, useMemo, type ComponentType, type ReactNode, } from "react"; import { HttpApi } from "effect/unstable/httpapi"; import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; import type { HealthCheckSpec } from "./health-check"; // --------------------------------------------------------------------------- // Re-exports — the curated set of primitives a plugin author needs to // build a typed reactive UI without reaching into `effect/*` directly. // --------------------------------------------------------------------------- export { Schema } from "effect"; export { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; export * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; export * as Atom from "effect/unstable/reactivity/Atom"; export * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; export { useAtomValue, useAtomSet, useAtomMount, useAtomRefresh } from "@effect/atom-react"; // --------------------------------------------------------------------------- // defineClientPlugin — declarative spec for the frontend half of a plugin. // // Mirror of `definePlugin` on the server, but everything here is React / // browser-only. The host treats the value as data: collects routes, // widgets, and slot components from every loaded plugin and mounts them // alongside the host's own UI. // --------------------------------------------------------------------------- export interface PluginPageProps { /** Plugin-relative route params captured from `PageDecl.path` segments. */ readonly params: Readonly<Record<string, string>>; /** The normalized plugin-relative URL path that matched this page. */ readonly path: string; /** The plugin id from `/plugins/$pluginId/...`. */ readonly pluginId: string; } export interface PageDecl { /** Path relative to the plugin's mount point, e.g. `/`, `/edit/$id`. */ readonly path: string; readonly component: ComponentType<PluginPageProps>; /** Optional sidebar nav metadata — the host renders these alongside its * own nav links. Omit to register a page without a nav entry. */ readonly nav?: { readonly label: string; readonly section?: string; }; } export interface WidgetProps { readonly scopeId?: string; } export interface WidgetDecl { readonly id: string; readonly component: ComponentType<WidgetProps>; readonly size?: "half" | "full"; } /** * Open record of host-defined slot components a plugin can fill. Slot * names are part of the host UI contract — plugins opt in by registering * a component for the slot they care about. Adding a slot is a host-side * change; plugin authors don't define new slots. */ export type SlotComponent = ComponentType<Record<string, unknown>>; // --------------------------------------------------------------------------- // IntegrationPlugin / IntegrationPreset — UI contract for plugins that expose // "integrations" (OpenAPI specs, MCP servers, GraphQL endpoints, etc.). The // host owns the integration list / detail chrome; the plugin owns the // add-flow, edit form, and (optional) summary + sign-in buttons. // // Lives here, not in `@executor-js/react`, so it's part of the plugin // contract: a plugin's `./client` entry assembles its `integrationPlugin` // alongside `pages`/`widgets`, and the host derives the union list // from `virtual:executor/plugins-client`. // --------------------------------------------------------------------------- export interface IntegrationPreset { /** Unique id (e.g. "stripe", "github-graphql"). */ readonly id: string; readonly name: string; readonly summary: string; /** URL passed as `initialUrl` to the add form. Omit for presets that * don't use a URL (e.g. stdio MCP presets). */ readonly url?: string; /** Endpoint passed to agent-facing probe/add tools when their schema * uses `endpoint` instead of `url`. */ readonly endpoint?: string; /** Optional icon URL (favicon, logo). */ readonly icon?: string; /** Image to show when `icon` cannot be resolved on this machine — a preset * whose icon is read from a local install has none until that install * exists, which is exactly when the card most needs to identify itself. */ readonly fallbackIcon?: string; /** Shown in the top-level grid on the integrations page when true. */ readonly featured?: boolean; readonly family?: string; readonly specFormat?: string; readonly defaultSlug?: string; /** Plugin-specific RFC 6902 operations applied to a fetched specification. */ readonly specOverrides?: readonly unknown[]; readonly authTemplate?: readonly IntegrationPresetAuthentication[]; readonly healthCheck?: HealthCheckSpec; /** The public registry lists this product, so the picker shows the * registry's card instead of a preset card (the preset's knowledge still * rides quick add). Built-in presets set this; a deployment's custom * preset does not, and keeps its own card — hiding it would make a * private API undiscoverable. */ readonly registryListed?: boolean; } export type IntegrationPresetAuthentication = | { readonly slug: string; readonly kind: "oauth2"; readonly label?: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly resource?: string | null; readonly scopes: readonly string[]; readonly supportsClientIdMetadataDocument?: boolean; } | { readonly kind: "apiKey"; readonly slug?: string; readonly name?: string; readonly placements?: readonly unknown[]; readonly headers?: Readonly<Record<string, readonly unknown[]>>; readonly queryParams?: Readonly<Record<string, readonly unknown[]>>; readonly cookies?: Readonly<Record<string, readonly unknown[]>>; }; export interface IntegrationAccountHandoff { /** Changes on each handoff URL, so the accounts UI can open once per link. */ readonly key: string; readonly owner?: "org" | "user"; /** Auth template/method to preselect when present. */ readonly template?: string; /** Non-secret connection label to prefill. */ readonly label?: string; /** Existing display identity to preserve when reconnecting a saved row. */ readonly identityLabel?: string; /** Present when the agent handed off a CONFIDENTIAL OAuth-app registration * (via `oauth.clients.createHandoff`) or when a saved OAuth connection is * reconnecting through its stored app. Registration opens the * Register-OAuth-app form pre-filled with these NON-secret fields, and the * human types the client secret directly into the browser. Reconnect starts * OAuth with the existing public client. */ readonly oauthClient?: { readonly action?: "register" | "reconnect"; /** Preselected client slug; when set the form's slug is fixed. */ readonly slug?: string; readonly owner?: "org" | "user"; readonly grant?: string; readonly clientId?: string; readonly authorizationUrl?: string; readonly tokenUrl?: string; /** RFC 8707 resource indicator. On a reconnect handoff this is the STORED * client's value, and an EXPLICIT null means the stored client was * registered WITHOUT a resource indicator — that absence must survive a * re-registration (some servers reject any `resource` parameter). * Undefined means no stored value was carried. */ readonly resource?: string | null; /** Reconnect only: the stored client binding is an auto-minted DCR client * (or its row is gone), so the modal may re-run the automatic * probe/registration flow. Absent or false pins the reconnect to the * stored client — a static/BYO or first-party binding must never be * silently rebound to an automatic client. */ readonly dynamicRegistration?: boolean; }; } /** Outcome of applying an edit-sheet section's staged change. `summary` is * toasted on success; `ok: false` keeps the sheet open (the section renders * its own error inline). */ export type EditSheetApplyResult = | { readonly ok: true; readonly summary: string | null } | { readonly ok: false }; export interface EditSheetSectionProps { readonly integrationId: string; readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; } /** What a registry row already knows about a connect target — enough, for * some plugins, to register without showing the configuration screen. */ export interface IntegrationQuickAddInput { /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ readonly url: string; /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ readonly name: string; /** Registry surface slug, used as the namespace seed when present. */ readonly slug?: string; /** The registry PRODUCT's domain (notion.com) — the identity credential * guidance and favicons key on. Often differs from the connect URL's * host: specs live on code hosts. */ readonly domain?: string; /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ readonly authHeader?: string; /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ readonly authKind?: string; /** RFC 6902 patch the registry says to apply to the fetched spec. */ readonly specOverrides?: readonly unknown[]; } export type IntegrationQuickAddResult = /** Registered; `slug` is the created integration's namespace. */ | { readonly ok: true; readonly slug: string } /** Could not add headlessly — the host falls back to the configuration * screen, which renders the failure with full context. */ | { readonly ok: false; readonly reason: string }; export interface IntegrationPlugin { /** Unique key matching the SDK plugin id (e.g. "openapi"). */ readonly key: string; readonly label: string; readonly add: ComponentType<{ /** Called when the integration has been registered. Receives the slug of * the just-registered integration, so the host can route to its detail * hub (`/integrations/<slug>`). Optional so existing no-arg calls still * typecheck while plugins are threading the slug through. */ readonly onComplete: (slug?: string) => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string; readonly initialNamespace?: string; /** Registry-declared credential placement for the surface, e.g. * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) * seed their auth-method editor from it. */ readonly initialAuthHeader?: string; readonly initialAuthNote?: string; /** Registry-declared credential kind ("none", "oauth", "api_key", …). * Lets a flow whose live probe fails, or whose surface can't be probed, * still declare the right method — an authless MCP server is a fact the * registry already knows. */ readonly initialAuthKind?: string; /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched * spec — the registry's mechanism for improving a vendor's published * document over time without hosting a fork. */ readonly initialSpecOverrides?: string; }>; /** Legacy full-page edit surface. No host renders this anymore — plugin * configuration lives in the integration Edit sheet via `editSheet`. */ readonly edit?: ComponentType<{ readonly integrationId: string; readonly onSave: () => void; }>; /** Plugin-owned configuration rendered inside the integration's Edit sheet, * below the shared metadata fields (e.g. the OpenAPI spec-update controls). * The sheet has ONE Save: the section stages its pending change locally and * reports it through `onPendingChange` — a thunk that applies the staged * change. Save runs the metadata update, then the staged apply; a failed * apply keeps the sheet open with the section showing its own error. */ readonly editSheet?: ComponentType<EditSheetSectionProps>; readonly summary?: ComponentType<{ readonly integrationId: string; readonly variant?: "badge" | "panel"; readonly onAction?: () => void; }>; /** Renders the integration's Accounts hub (auth methods + connections) inside * the detail page's Accounts tab. Plugins that declare auth methods implement * this; the page falls back to a generic accounts list when absent. */ readonly accounts?: ComponentType<{ readonly integrationId: string; readonly integrationName: string; readonly accountHandoff?: IntegrationAccountHandoff | null; }>; readonly presets?: readonly IntegrationPreset[]; /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). * Call from the host on intent (hover/focus) so the chunks land before the * user navigates into the add page. Idempotent. */ readonly preload?: () => void; /** Headless one-click add for a registry row whose facts are already known * (URL + auth indicators). A HOOK, not a plain function, because each * plugin binds its own mutation atoms — the host mounts one bridge * component per plugin to collect the bound callbacks. Absent means the * plugin always needs its configuration screen. A `{ok: false}` result is * an expected outcome (unreachable server, slug collision), not an error: * the host falls back to the configuration screen prefilled with the same * facts. */ readonly useQuickAdd?: () => ( input: IntegrationQuickAddInput, ) => Promise<IntegrationQuickAddResult>; } // --------------------------------------------------------------------------- // SecretProviderPlugin — UI contract for plugins that contribute secret // providers (1Password, WorkOS Vault, etc.). The host owns the secrets // page chrome; the plugin owns the settings card rendered inside. // --------------------------------------------------------------------------- export interface SecretProviderPlugin { /** Unique key matching the SDK plugin id (e.g. "onepassword"). */ readonly key: string; readonly label: string; readonly settings: ComponentType<Record<string, never>>; } export interface ClientPluginSpec<TId extends string = string> { readonly id: TId; readonly pages?: readonly PageDecl[]; readonly widgets?: readonly WidgetDecl[]; readonly slots?: Record<string, SlotComponent>; /** Integration plugin contribution — populated by plugins that expose * `kind` rows in the core `integration` table (openapi, mcp, graphql). * The host's integrations page derives its provider * list from the union of every loaded plugin's `integrationPlugin`. */ readonly integrationPlugin?: IntegrationPlugin; /** Secret provider plugin contribution — populated by plugins that * also ship a `secretProviders` (or related) server-side capability * AND want to expose a settings card on the host's secrets page. */ readonly secretProviderPlugin?: SecretProviderPlugin; } /** * Identity factory — returns the spec unchanged but pins the inferred * literal type of `id` so the host can index plugin records by id with * full autocomplete. Plugins export this as their package's default * (or named) export from `./client`. */ export const defineClientPlugin = <const TId extends string>( spec: ClientPluginSpec<TId>, ): ClientPluginSpec<TId> => spec; // --------------------------------------------------------------------------- // createPluginAtomClient — typed reactive HTTP client for one plugin. // // Wraps the plugin's `HttpApiGroup` in a per-plugin `HttpApi`, then // hands back an `AtomHttpApi.Service` keyed to that bundle. The // resulting service exposes `.query("group", "endpoint", opts)` and // `.mutation("group", "endpoint")` factories — same shape as the host's // existing `ExecutorApiClient` (see packages/react/src/api/client.tsx). // Per-endpoint payload/response/error types flow through from the // imported group, so plugin client code typechecks without codegen. // // The plugin id (used for the Service Tag and the synthetic API id) is // read from `group.identifier` — the same string the plugin passed to // `HttpApiGroup.make("foo")`. No second-source duplication. // --------------------------------------------------------------------------- export interface CreatePluginAtomClientOptions { /** Override the base URL. Defaults to `/api` (host strips this prefix * when forwarding to the Effect handler) — same convention as the * core `ExecutorApiClient`. */ readonly baseUrl?: string | (() => string); /** Optional dynamic Authorization header for hosts whose active * Executor Server Connection requires Basic or Bearer auth. */ readonly authorizationHeader?: string | null | (() => string | null); /** Optional dynamic request headers supplied by the host shell. */ readonly headers?: | Readonly<Record<string, string | null | undefined>> | (() => Readonly<Record<string, string | null | undefined>>); } export interface PluginAtomClientRequestTransformOptions { readonly baseUrl?: () => string; readonly authorizationHeader?: string | null | (() => string | null); readonly headers?: | Readonly<Record<string, string | null | undefined>> | (() => Readonly<Record<string, string | null | undefined>>); } /** @internal */ export const applyPluginAtomClientRequestTransform = ( request: HttpClientRequest.HttpClientRequest, options: PluginAtomClientRequestTransformOptions, ): HttpClientRequest.HttpClientRequest => { let next = options.baseUrl ? HttpClientRequest.prependUrl(request, options.baseUrl()) : request; const authorization = typeof options.authorizationHeader === "function" ? options.authorizationHeader() : options.authorizationHeader; if (authorization) { next = HttpClientRequest.setHeader(next, "authorization", authorization); } const headers = typeof options.headers === "function" ? options.headers() : options.headers; if (headers) { for (const [name, value] of Object.entries(headers)) { if (value !== undefined && value !== null) { next = HttpClientRequest.setHeader(next, name, value); } } } return next; }; /** * Build a typed reactive client for a plugin's HttpApiGroup. * * const FooClient = createPluginAtomClient(FooApi) * export const fooThings = FooClient.query("foo", "listThings", { ... }) * export const fooSync = FooClient.mutation("foo", "syncThing") * * Each plugin gets a private service Tag (`Plugin_<id>Client`) keyed by * the group's `identifier`, so multiple plugins coexist in the same * React tree without colliding. */ export const createPluginAtomClient = < G extends HttpApiGroup.HttpApiGroup<string, HttpApiEndpoint.Any, boolean>, >( group: G, options: CreatePluginAtomClientOptions = {}, ) => { const { baseUrl = "/api", authorizationHeader, headers } = options; const pluginId = group.identifier; const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; const staticBaseUrl = typeof baseUrl === "function" ? undefined : baseUrl; const getAuthorizationHeader = typeof authorizationHeader === "function" ? authorizationHeader : null; const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; const hasHeaders = headers !== undefined; const transformClient = getBaseUrl || hasAuthorization || hasHeaders ? HttpClient.mapRequest((request) => applyPluginAtomClientRequestTransform(request, { ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), ...(getAuthorizationHeader ? { authorizationHeader: getAuthorizationHeader } : authorizationHeader !== undefined ? { authorizationHeader } : {}), ...(headers !== undefined ? { headers } : {}), }), ) : undefined; return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()(`Plugin_${pluginId}Client`, { api: bundle, httpClient: FetchHttpClient.layer, ...(staticBaseUrl !== undefined ? { baseUrl: staticBaseUrl } : {}), ...(transformClient ? { transformClient } : {}), }); }; // --------------------------------------------------------------------------- // ExecutorPluginsProvider + hooks — host-level distribution of the loaded // `ClientPluginSpec[]` via React context. // // The host wraps once at the root of its tree (typically reading from // `virtual:executor/plugins-client`); pages and shared components consume // via the focused hooks (`useIntegrationPlugins` etc.) so they don't import // from any host-app aggregator file. Pages stay portable across hosts — // the same component renders against whatever plugin set the surrounding // `<ExecutorPluginsProvider>` provides. // // Hooks throw if no provider is in scope so missing setup fails loudly; // matches the pattern of `useScope` / `useAuth` already in the codebase. // --------------------------------------------------------------------------- interface ExecutorPluginsContextValue { readonly plugins: readonly ClientPluginSpec[]; readonly integrationPlugins: readonly IntegrationPlugin[]; readonly secretProviderPlugins: readonly SecretProviderPlugin[]; } const ExecutorPluginsContext = createContext<ExecutorPluginsContextValue | null>(null); ExecutorPluginsContext.displayName = "ExecutorPluginsContext"; export interface ExecutorPluginsProviderProps { readonly plugins: readonly ClientPluginSpec[]; readonly children: ReactNode; } export function ExecutorPluginsProvider( props: ExecutorPluginsProviderProps, ): ReturnType<typeof createElement> { const { plugins, children } = props; const value = useMemo<ExecutorPluginsContextValue>( () => ({ plugins, integrationPlugins: plugins.flatMap((p) => p.integrationPlugin ? [p.integrationPlugin] : [], ), secretProviderPlugins: plugins.flatMap((p) => p.secretProviderPlugin ? [p.secretProviderPlugin] : [],