307 lines · 12.5 KB · TypeScript
RawDownload
import { useEffect, useRef, useState } from "react";import { useAtomValue, useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Button } from "@executor-js/react/components/button";import { FloatActions } from "@executor-js/react/components/float-actions";import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add"; import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms";import { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate";import { CODEX_PERMISSIONS } from "../sdk/codex-permissions"; // ---------------------------------------------------------------------------// Focused add screen for one Codex plugin, reached from its catalog preset// (e.g. searching "imessage" in the connect dialog). The preset is only a// pointer; everything shown here — icon, availability, spawn recipe — comes// from the server-side scanner reading the user's local Codex install. One// primary action: Add. No transport toggle, no manual command form.// --------------------------------------------------------------------------- export default function CodexPluginAdd(props: {  readonly presetId: string;  readonly onComplete: (slug?: string) => void;  readonly onCancel: () => void;}) {  const pluginsResult = useAtomValue(codexPluginsAtom);  const integrationsResult = useAtomValue(integrationsOptimisticAtom);  const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });  const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" });   const [adding, setAdding] = useState(false);  const [access, setAccess] = useState<CodexAccessState | null>(null);  const [checking, setChecking] = useState(false);  const [error, setError] = useState<string | null>(null);   const plugin = AsyncResult.isSuccess(pluginsResult)    ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId)    : undefined;   const permissions = CODEX_PERMISSIONS[props.presetId] ?? [];   const added =    plugin !== undefined &&    AsyncResult.isSuccess(integrationsResult) &&    integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug);   // Adding is held back until the probe says the plugin can actually run: an  // integration added while macOS is blocking it looks connected and fails on  // its first real call, by which point the person has left the one card that  // explains the fix.  const blocked = accessBlocked(access);  const pending = accessPending({    checking,    declaresPermissions: permissions.length > 0,    access,  });   const handleAdd = async () => {    if (plugin === undefined) return;    setAdding(true);    setError(null);    const exit = await doAddServer({      payload: {        transport: "stdio" as const,        name: plugin.name,        slug: plugin.slug,        description: plugin.summary,        command: plugin.command,        args: [...plugin.args],        ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),        // As STATIC env, not `env`: these are machine-derived paths the        // scanner already resolved, so they must not become credentials the        // person is asked to type. Sent this way the integration declares no        // auth, and its connection is created for them.        ...(plugin.env !== undefined ? { staticEnv: { ...plugin.env } } : {}),        ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),      },      reactivityKeys: integrationWriteKeys,    });    if (Exit.isFailure(exit)) {      setError(addIntegrationErrorMessage(exit, plugin.slug, "Failed to add plugin"));      setAdding(false);      return;    }    props.onComplete(exit.value.slug);  };   // Checked on open rather than on a button, so the card states where you  // stand before you commit to anything. It runs the plugin's read-only probe  // once per card: macOS offers no way to READ another app's privacy  // decisions, so trying it is the only way to know.  const checkedFor = useRef<string | null>(null);  useEffect(() => {    if (!plugin?.available) return;    if (checkedFor.current === props.presetId) return;    checkedFor.current = props.presetId;    void handleCheck();    // `handleCheck` is stable for a given preset; re-running on its identity    // would re-probe on every render.    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [plugin?.available, props.presetId]);   const handleCheck = async () => {    setChecking(true);    setAccess(null);    // Runs the plugin's own read-only probe down the real path. When macOS has    // not asked yet, THIS is what makes it ask — so the check doubles as the    // grant flow.    // Reads nothing and writes nothing, so no cache is invalidated by it.    const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] });    setAccess(      Exit.isSuccess(exit)        ? (exit.value as CodexAccessState)        : { status: "blocked", message: "Could not reach the plugin." },    );    setChecking(false);  };   if (!AsyncResult.isSuccess(pluginsResult)) {    return (      <div className="flex flex-1 flex-col gap-6">        <p className="text-[13px] text-muted-foreground">Checking this machine for Codex…</p>      </div>    );  }   if (plugin === undefined) {    return (      <div className="flex flex-1 flex-col gap-6">        <p className="text-[13px] text-muted-foreground">          This Codex plugin was not found on this machine.        </p>        <FloatActions>          <Button type="button" variant="ghost" onClick={() => props.onCancel()}>            Back          </Button>        </FloatActions>      </div>    );  }   return (    <div className="flex flex-1 flex-col gap-6">      {/* Mirrors the plugin's own page in Codex: its icon, display name,          tagline, and long description, all read from the local install. */}      <div className="flex flex-col gap-4">        {/* The plugin's own icon comes from the local Codex install; without            one the card still identifies its provider rather than showing a            gap, which matters most on the machines that have no install. */}        <IntegrationFavicon          icon={plugin.icon}          fallbackSrc={plugin.fallbackIcon ?? "https://integrations.sh/logo/openai.com"}          size={64}          className="rounded-2xl"        />        <div className="min-w-0">          <div className="flex items-baseline gap-2">            <h1 className="text-xl font-semibold text-foreground">              {plugin.displayName ?? plugin.name}            </h1>            <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">              Codex plugin            </span>          </div>          <p className="mt-1 text-[13px] text-muted-foreground">            {plugin.tagline ?? plugin.summary}          </p>        </div>        {plugin.description !== undefined && (          <p className="max-w-prose text-[13px] leading-relaxed text-muted-foreground">            {plugin.description}          </p>        )}      </div>       <div className="flex flex-col gap-1 rounded-lg border border-border px-3 py-2.5">        <div className="flex items-baseline justify-between gap-3">          <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">            Status          </span>          <span className="font-mono text-[11px] text-muted-foreground">            {added ? "Added" : plugin.available ? "Ready" : "Not installed on this Mac"}          </span>        </div>        {!plugin.available && plugin.setupHint !== undefined && (          // The hint arrives as numbered lines; render them as the list they          // are, so the reader sees an ordered path rather than a paragraph.          <ol className="mt-1 flex list-none flex-col gap-1.5">            {plugin.setupHint.split("\n").map((step) => (              <li key={step} className="text-[12px] leading-relaxed text-muted-foreground">                {step}              </li>            ))}          </ol>        )}        {plugin.available && !added && (          <p className="text-[12px] text-muted-foreground">            Runs the plugin from your Codex install. Nothing is downloaded.          </p>        )}      </div>       {/* Stated up front, not discovered on first failure: macOS asks for          these ONCE, and a dismissed prompt never returns — after that the          plugin fails with an error the user cannot act on. Each row links          straight to the pane that holds the switch. */}      {permissions.length > 0 && (        <div className="flex flex-col gap-2 rounded-lg border border-border px-3 py-2.5">          <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">            macOS access          </span>          <p className="text-[12px] text-muted-foreground">            {blocked              ? "This has to be on before the plugin can be added — turn it on below, then check again."              : "macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here."}          </p>          <div className="flex items-center gap-3">            <span              className={                access !== null && access.status !== "ok"                  ? "text-[12px] text-destructive"                  : "text-[12px] text-muted-foreground"              }            >              {checking                ? "Checking…"                : access === null                  ? ""                  : access.status === "ok"                    ? "Allowed — macOS is letting this through."                    : (access.message ?? "Blocked.")}            </span>            {access !== null && access.status !== "ok" && !checking && (              <Button type="button" variant="secondary" onClick={() => void handleCheck()}>                Check again              </Button>            )}          </div>          <ul className="flex flex-col gap-1.5">            {permissions.map((permission) => (              <li key={permission.id} className="flex items-baseline justify-between gap-3">                <span className="text-[12px] text-muted-foreground">                  <span className="text-foreground">{permission.label}</span> — {permission.why}                </span>                <a                  href={permission.settingsUrl}                  className="shrink-0 font-mono text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"                >                  Open settings                </a>              </li>            ))}          </ul>        </div>      )}       {/* A block on a plugin that declares no macOS access has no panel above          to carry it, so state the reason next to the action it is holding. */}      {blocked && permissions.length === 0 && (        <div className="flex items-center gap-3">          <p className="text-[12px] text-destructive">            {access?.message ?? "This plugin cannot run yet."}          </p>          <Button type="button" variant="secondary" onClick={() => void handleCheck()}>            Check again          </Button>        </div>      )}       {error !== null && <p className="text-[12px] text-destructive">{error}</p>}       <FloatActions>        <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={adding}>          Cancel        </Button>        {added ? (          <Button type="button" onClick={() => props.onComplete(plugin.slug)}>            View integration          </Button>        ) : plugin.available ? (          <Button            type="button"            onClick={() => void handleAdd()}            loading={adding}            disabled={blocked || pending}          >            {pending ? "Checking access…" : "Add integration"}          </Button>        ) : (          <Button asChild>            <a              href={plugin.setupUrl ?? "https://openai.com/codex"}              target="_blank"              rel="noreferrer"            >              Install Codex            </a>          </Button>        )}      </FloatActions>    </div>  );}