History

Focused add screen and real icons for Codex plugins

Rhys Sullivan committed 2 months ago

Commit 7f2cfa41 parent f0f38a76 files · +243 −174Browse files

Unified · Split

6 files
  1. packages/plugins/mcp/src/api/group.ts +2 −0
  2. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +17 −13
  3. packages/plugins/mcp/src/react/CodexPluginAdd.tsx +149 −0
  4. packages/plugins/mcp/src/react/CodexPluginsSection.tsx +0 −144
  5. packages/plugins/mcp/src/sdk/codex-plugins.ts +68 −13
  6. packages/plugins/mcp/src/sdk/presets.ts +7 −4
packages/plugins/mcp/src/api/group.ts+2−0
@@ -145,6 +145,8 @@ const CodexPluginEntrySchema = Schema.Struct({ · 144 unmodified lines
145145 cwd: Schema.optional(Schema.String),
146146 env: Schema.optional(StringMap),
147147 setupHint: Schema.optional(Schema.String),
148+ /** The plugin's own icon from its local install, as a data URI. */
149+ icon: Schema.optional(Schema.String),
148150 });
149151
150152 const ListCodexPluginsResponse = Schema.Struct({
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+17−13
@@ -41,7 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines
4141 import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
4242 import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
4343 import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44−import { CodexPluginsSection } from "./CodexPluginsSection";
44+import CodexPluginAdd from "./CodexPluginAdd";
4545 import { parseStdioArgs } from "./stdio-fields";
4646 import { isProbableMcpEndpoint } from "./probe-url";
4747 import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
@@ -169,15 +169,14 @@ export default function AddMcpIntegration(props: { · 121 unmodified lines
169169 // Drop stdio presets when stdio is disabled — the caller should have
170170 // already filtered these out, but defence-in-depth.
171171 const preset = rawPreset?.transport === "stdio" && !allowStdio ? undefined : rawPreset;
172− // A Codex plugin preset is a catalog pointer, not a spawn recipe: it opens
173− // the stdio tab and highlights the matching Codex-plugins card (which
174− // carries the server-resolved command and availability) instead of
175− // prefilling the manual form.
172+ // A Codex plugin preset is a catalog pointer, not a spawn recipe: it gets
173+ // its own focused add screen (rendered below, before the generic form),
174+ // fed by the server-side scanner.
176175 const isCodexPreset = isCodexPresetId(preset?.id);
177176 const isStdioPreset = preset?.transport === "stdio" && !isCodexPreset;
178177
179178 const [transport, setTransport] = useState<"remote" | "stdio">(
180− (isStdioPreset || isCodexPreset) && allowStdio ? "stdio" : "remote",
179+ isStdioPreset && allowStdio ? "stdio" : "remote",
181180 );
182181
183182 // --- Stdio state ---
@@ -398,6 +397,18 @@ export default function AddMcpIntegration(props: { · 214 unmodified lines
398397
399398 // ---- Render ----
400399
400+ // Placed after every hook so the hook order is identical on all renders;
401+ // `isCodexPreset` is fixed for the component's lifetime (route search param).
402+ if (isCodexPreset && preset) {
403+ return (
404+ <CodexPluginAdd
405+ presetId={preset.id}
406+ onComplete={props.onComplete}
407+ onCancel={props.onCancel}
408+ />
409+ );
410+ }
411+
401412 return (
402413 <div className="flex flex-1 flex-col gap-6">
403414 <div>
@@ -525,13 +536,6 @@ export default function AddMcpIntegration(props: { · 121 unmodified lines
525536 </>
526537 ) : (
527538 <>
528− {/* Locally installed Codex plugins — one-click presets, with an
529− install hint for entries whose binaries are missing. */}
530− <CodexPluginsSection
531− onComplete={(slug) => props.onComplete(slug)}
532− {...(isCodexPreset && preset ? { highlightId: preset.id } : {})}
533− />
534−
535539 {/* Stdio form */}
536540 <CardStack>
537541 <CardStackContent className="border-t-0">
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+149−0
@@ -0,0 +1,149 @@
1+import { useState } from "react";
2+import { useAtomValue, useAtomSet } from "@effect/atom-react";
3+import * as Exit from "effect/Exit";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+
6+import { Button } from "@executor-js/react/components/button";
7+import { FloatActions } from "@executor-js/react/components/float-actions";
8+import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
9+import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
10+import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
11+
12+import { addMcpServer, codexPluginsAtom } from "./atoms";
13+
14+// ---------------------------------------------------------------------------
15+// Focused add screen for one Codex plugin, reached from its catalog preset
16+// (e.g. searching "imessage" in the connect dialog). The preset is only a
17+// pointer; everything shown here — icon, availability, spawn recipe — comes
18+// from the server-side scanner reading the user's local Codex install. One
19+// primary action: Add. No transport toggle, no manual command form.
20+// ---------------------------------------------------------------------------
21+
22+export default function CodexPluginAdd(props: {
23+ readonly presetId: string;
24+ readonly onComplete: (slug?: string) => void;
25+ readonly onCancel: () => void;
26+}) {
27+ const pluginsResult = useAtomValue(codexPluginsAtom);
28+ const integrationsResult = useAtomValue(integrationsOptimisticAtom);
29+ const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
30+
31+ const [adding, setAdding] = useState(false);
32+ const [error, setError] = useState<string | null>(null);
33+
34+ const plugin = AsyncResult.isSuccess(pluginsResult)
35+ ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId)
36+ : undefined;
37+
38+ const added =
39+ plugin !== undefined &&
40+ AsyncResult.isSuccess(integrationsResult) &&
41+ integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug);
42+
43+ const handleAdd = async () => {
44+ if (plugin === undefined) return;
45+ setAdding(true);
46+ setError(null);
47+ const exit = await doAddServer({
48+ payload: {
49+ transport: "stdio" as const,
50+ name: plugin.name,
51+ slug: plugin.slug,
52+ description: plugin.summary,
53+ command: plugin.command,
54+ args: [...plugin.args],
55+ ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),
56+ ...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}),
57+ },
58+ reactivityKeys: integrationWriteKeys,
59+ });
60+ if (Exit.isFailure(exit)) {
61+ setError(addIntegrationErrorMessage(exit, plugin.slug, "Failed to add plugin"));
62+ setAdding(false);
63+ return;
64+ }
65+ props.onComplete(exit.value.slug);
66+ };
67+
68+ if (!AsyncResult.isSuccess(pluginsResult)) {
69+ return (
70+ <div className="flex flex-1 flex-col gap-6">
71+ <p className="text-[13px] text-muted-foreground">Checking this machine for Codex…</p>
72+ </div>
73+ );
74+ }
75+
76+ if (plugin === undefined) {
77+ return (
78+ <div className="flex flex-1 flex-col gap-6">
79+ <p className="text-[13px] text-muted-foreground">
80+ This Codex plugin was not found on this machine.
81+ </p>
82+ <FloatActions>
83+ <Button type="button" variant="ghost" onClick={() => props.onCancel()}>
84+ Back
85+ </Button>
86+ </FloatActions>
87+ </div>
88+ );
89+ }
90+
91+ return (
92+ <div className="flex flex-1 flex-col gap-6">
93+ <div className="flex items-start gap-4">
94+ {plugin.icon !== undefined && (
95+ <img
96+ src={plugin.icon}
97+ alt=""
98+ className="mt-0.5 size-12 shrink-0 rounded-xl border border-border"
99+ />
100+ )}
101+ <div className="min-w-0">
102+ <h1 className="text-xl font-semibold text-foreground">{plugin.name}</h1>
103+ <p className="mt-1 text-[13px] text-muted-foreground">{plugin.summary}</p>
104+ </div>
105+ </div>
106+
107+ <div className="flex flex-col gap-1 rounded-lg border border-border px-3 py-2.5">
108+ <div className="flex items-baseline justify-between gap-3">
109+ <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
110+ Status
111+ </span>
112+ <span className="font-mono text-[11px] text-muted-foreground">
113+ {added ? "Added" : plugin.available ? "Ready" : "Requires Codex"}
114+ </span>
115+ </div>
116+ {!plugin.available && plugin.setupHint !== undefined && (
117+ <p className="text-[12px] text-muted-foreground">{plugin.setupHint}</p>
118+ )}
119+ {plugin.available && !added && (
120+ <p className="text-[12px] text-muted-foreground">
121+ Runs the plugin from your Codex install. Nothing is downloaded.
122+ </p>
123+ )}
124+ </div>
125+
126+ {error !== null && <p className="text-[12px] text-destructive">{error}</p>}
127+
128+ <FloatActions>
129+ <Button type="button" variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
130+ Cancel
131+ </Button>
132+ {added ? (
133+ <Button type="button" onClick={() => props.onComplete(plugin.slug)}>
134+ View integration
135+ </Button>
136+ ) : (
137+ <Button
138+ type="button"
139+ onClick={() => void handleAdd()}
140+ disabled={!plugin.available}
141+ loading={adding}
142+ >
143+ Add integration
144+ </Button>
145+ )}
146+ </FloatActions>
147+ </div>
148+ );
149+}
packages/plugins/mcp/src/react/CodexPluginsSection.tsx+0−144
@@ -1,144 +0,0 @@
1−import { useState } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−
6−import { Button } from "@executor-js/react/components/button";
7−import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";
8−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
9−import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
10−
11−import { addMcpServer, codexPluginsAtom } from "./atoms";
12−
13−// ---------------------------------------------------------------------------
14−// Codex plugins — one-click stdio presets for OpenAI Codex plugins found on
15−// this machine (Apple Messages, Computer Use, Computer History, plus anything
16−// else in the plugin cache with a local MCP server). Entries whose binaries
17−// are missing still render, with the install hint instead of an Add action:
18−// the integration stays discoverable on a machine without Codex, and nothing
19−// of OpenAI's ships with executor to make that happen.
20−// ---------------------------------------------------------------------------
21−
22−type CodexPluginRow = {
23− readonly id: string;
24− readonly name: string;
25− readonly summary: string;
26− readonly available: boolean;
27− readonly slug: string;
28− readonly command: string;
29− readonly args: readonly string[];
30− readonly cwd?: string;
31− readonly env?: Readonly<Record<string, string>>;
32− readonly setupHint?: string;
33−};
34−
35−export function CodexPluginsSection(props: {
36− readonly onComplete: (slug: string) => void;
37− /** Card to emphasise — set when the user arrived via a Codex catalog
38− * preset (e.g. searched "imessage" in the connect dialog). */
39− readonly highlightId?: string;
40−}) {
41− const pluginsResult = useAtomValue(codexPluginsAtom);
42− const integrationsResult = useAtomValue(integrationsOptimisticAtom);
43− const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
44−
45− const [addingId, setAddingId] = useState<string | null>(null);
46− const [errors, setErrors] = useState<Readonly<Record<string, string>>>({});
47−
48− if (!AsyncResult.isSuccess(pluginsResult)) return null;
49− const plugins: readonly CodexPluginRow[] = pluginsResult.value.plugins;
50− if (plugins.length === 0) return null;
51−
52− const existingSlugs = new Set(
53− AsyncResult.isSuccess(integrationsResult)
54− ? integrationsResult.value.map((integration) => String(integration.slug))
55− : [],
56− );
57−
58− const handleAdd = async (plugin: CodexPluginRow) => {
59− setAddingId(plugin.id);
60− setErrors((prev) => ({ ...prev, [plugin.id]: "" }));
61− const exit = await doAddServer({
62− payload: {
63− transport: "stdio" as const,
64− name: plugin.name,
65− slug: plugin.slug,
66− description: plugin.summary,
67− command: plugin.command,
68− args: [...plugin.args],
69− ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),
70− ...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}),
71− },
72− reactivityKeys: integrationWriteKeys,
73− });
74− if (Exit.isFailure(exit)) {
75− setErrors((prev) => ({
76− ...prev,
77− [plugin.id]: addIntegrationErrorMessage(exit, plugin.slug, "Failed to add plugin"),
78− }));
79− setAddingId(null);
80− return;
81− }
82− props.onComplete(exit.value.slug);
83− };
84−
85− return (
86− <div className="flex flex-col gap-2">
87− <div className="flex items-baseline justify-between">
88− <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
89− Codex plugins
90− </span>
91− <span className="font-mono text-[11px] text-muted-foreground">
92− {plugins.filter((plugin) => plugin.available).length}/{plugins.length} available
93− </span>
94− </div>
95− <div className="divide-y divide-border rounded-lg border border-border">
96− {plugins.map((plugin) => {
97− const added = existingSlugs.has(plugin.slug);
98− const error = errors[plugin.id];
99− const highlighted = plugin.id === props.highlightId;
100− return (
101− <div
102− key={plugin.id}
103− className={`flex flex-col gap-1 px-3 py-2.5 ${highlighted ? "bg-muted/40" : ""}`}
104− >
105− <div className="flex items-center justify-between gap-3">
106− <div className="min-w-0">
107− <p className="truncate text-sm font-medium text-foreground">{plugin.name}</p>
108− <p className="mt-0.5 text-[12px] text-muted-foreground">{plugin.summary}</p>
109− </div>
110− {added ? (
111− <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
112− Added
113− </span>
114− ) : plugin.available ? (
115− <Button
116− type="button"
117− variant="outline"
118− size="sm"
119− onClick={() => void handleAdd(plugin)}
120− loading={addingId === plugin.id}
121− disabled={addingId !== null}
122− className="shrink-0"
123− >
124− Add
125− </Button>
126− ) : (
127− <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
128− Requires Codex
129− </span>
130− )}
131− </div>
132− {!plugin.available && plugin.setupHint !== undefined && (
133− <p className="text-[11px] text-muted-foreground">{plugin.setupHint}</p>
134− )}
135− {error !== undefined && error.length > 0 && (
136− <p className="text-[12px] text-destructive">{error}</p>
137− )}
138− </div>
139− );
140− })}
141− </div>
142− </div>
143− );
144−}
packages/plugins/mcp/src/sdk/codex-plugins.ts+68−13
@@ -40,6 +40,9 @@ export interface CodexPluginEntry { · 39 unmodified lines
4040 readonly env?: Readonly<Record<string, string>>;
4141 /** Shown when `available` is false. */
4242 readonly setupHint?: string;
43+ /** The plugin's own icon from its local install, as a data URI. Read at
44+ * runtime from the user's disk — never shipped with executor. */
45+ readonly icon?: string;
4346 }
4447
4548 /** The Codex Computer Use client binary — the stable, unversioned entry point
@@ -75,6 +78,7 @@ const PluginManifest = Schema.Struct({ · 29 unmodified lines
7578 Schema.Struct({
7679 displayName: Schema.optional(Schema.String),
7780 shortDescription: Schema.optional(Schema.String),
81+ logo: Schema.optional(Schema.String),
7882 }),
7983 ),
8084 description: Schema.optional(Schema.String),
@@ -159,6 +163,48 @@ const sanitizeId = (value: string): string => · 78 unmodified lines
159163 .replace(/[^a-z0-9]+/g, "-")
160164 .replace(/^-+|-+$/g, "");
161165
166+/** Bound on inlined icon bytes. The bundled icons run 100KB–1MB; anything
167+ * past this is not worth inlining into a list response for a 20px avatar. */
168+const MAX_ICON_BYTES = 1_000_000;
169+
170+const ICON_MIME: Readonly<Record<string, string>> = {
171+ ".png": "image/png",
172+ ".jpg": "image/jpeg",
173+ ".jpeg": "image/jpeg",
174+ ".webp": "image/webp",
175+ ".svg": "image/svg+xml",
176+};
177+
178+/** The plugin's own icon as a data URI, read from the user's local install. */
179+const readIconDataUri = (file: string): string | undefined =>
180+ tryOrElse(() => {
181+ const mime = ICON_MIME[path.extname(file).toLowerCase()];
182+ if (mime === undefined) return undefined;
183+ if (fs.statSync(file).size > MAX_ICON_BYTES) return undefined;
184+ return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
185+ }, undefined);
186+
187+/** Icon for a curated plugin: its cache entry (any source, newest version)
188+ * declares a `logo` path in the manifest. The curated SPAWN target is the
189+ * stable client binary, but the icon only exists in the cache. */
190+const curatedIconDataUri = (codexHome: string, pluginName: string): string | undefined => {
191+ const cacheDir = path.join(codexHome, "plugins", "cache");
192+ for (const sourceName of listDirs(cacheDir)) {
193+ const pluginDir = path.join(cacheDir, sourceName, pluginName);
194+ const version = [...listDirs(pluginDir)].sort(compareVersionsDesc)[0];
195+ if (version === undefined) continue;
196+ const versionDir = path.join(pluginDir, version);
197+ const manifest = Option.getOrUndefined(
198+ decodeManifestJson(readText(path.join(versionDir, ".codex-plugin", "plugin.json"))),
199+ );
200+ const logo = manifest?.interface?.logo;
201+ if (logo === undefined) continue;
202+ const icon = readIconDataUri(path.resolve(versionDir, logo));
203+ if (icon !== undefined) return icon;
204+ }
205+ return undefined;
206+};
207+
162208 // ---------------------------------------------------------------------------
163209 // Scan
164210 // ---------------------------------------------------------------------------
@@ -192,6 +238,10 @@ const scanCachedPlugin = ( · 27 unmodified lines
192238 manifest.interface?.shortDescription ??
193239 manifest.description?.split("\n")[0] ??
194240 `Local MCP server from the Codex plugin "${manifest.name}".`;
241+ const icon =
242+ manifest.interface?.logo === undefined
243+ ? undefined
244+ : readIconDataUri(path.resolve(versionDir, manifest.interface.logo));
195245
196246 const localServers = Object.entries(servers.mcpServers).flatMap(([serverKey, spec]) =>
197247 spec.command !== undefined && spec.type !== "http" && spec.url === undefined
@@ -226,6 +276,7 @@ const scanCachedPlugin = ( · 28 unmodified lines
226276 // the integration after adding it.
227277 env: { CODEX_HOME: codexHome },
228278 ...(available ? {} : { setupHint: CODEX_SETUP_HINT }),
279+ ...(icon === undefined ? {} : { icon }),
229280 };
230281 });
231282 };
@@ -248,19 +299,23 @@ export const scanCodexPlugins = (options?: { · 16 unmodified lines
248299 const client = clientBinaryPath(codexHome);
249300 const clientAvailable = isExecutableFile(client);
250301
251− const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => ({
252− id: entry.id,
253− name: entry.name,
254− summary: entry.summary,
255− available: clientAvailable,
256− slug: entry.slug,
257− source: "curated" as const,
258− command: client,
259− args: entry.args,
260− cwd: path.join(codexHome, "computer-use"),
261− env: { CODEX_HOME: codexHome },
262− ...(clientAvailable ? {} : { setupHint: CODEX_SETUP_HINT }),
263− }));
302+ const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => {
303+ const icon = curatedIconDataUri(codexHome, entry.pluginName);
304+ return {
305+ id: entry.id,
306+ name: entry.name,
307+ summary: entry.summary,
308+ available: clientAvailable,
309+ slug: entry.slug,
310+ source: "curated" as const,
311+ command: client,
312+ args: entry.args,
313+ cwd: path.join(codexHome, "computer-use"),
314+ env: { CODEX_HOME: codexHome },
315+ ...(clientAvailable ? {} : { setupHint: CODEX_SETUP_HINT }),
316+ ...(icon === undefined ? {} : { icon }),
317+ };
318+ });
264319
265320 const cacheDir = path.join(codexHome, "plugins", "cache");
266321 const scanned = listDirs(cacheDir).flatMap((sourceName) => {
packages/plugins/mcp/src/sdk/presets.ts+7−4
@@ -28,14 +28,17 @@ export interface McpStdioPreset { · 27 unmodified lines
2828 export type McpPreset = McpRemotePreset | McpStdioPreset;
2929
3030 // Codex plugin presets — searchable catalog entries ("imessage", "computer
31−// use", …) for the plugins the add form's Codex-plugins section installs.
32−// `command` is deliberately empty: the real spawn recipe is machine-specific
33−// and comes from the server-side scanner (`codex-plugins.ts`); picking one of
34−// these routes to the stdio tab with the matching card highlighted.
31+// use", …). `command` is deliberately empty: the real spawn recipe is
32+// machine-specific and comes from the server-side scanner
33+// (`codex-plugins.ts`); picking one of these opens the focused Codex add
34+// screen, which shows the plugin's own locally installed icon. The list icon
35+// is the OpenAI logo — the shared provenance of all three — because a static
36+// preset cannot reach the machine-local icon files.
3537 const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({
3638 id: plugin.id,
3739 name: plugin.name,
3840 summary: plugin.summary,
41+ icon: "https://integrations.sh/logo/openai.com",
3942 family: "codex",
4043 transport: "stdio",
4144 command: "",