Unified · Split
| @@ -145,6 +145,8 @@ const CodexPluginEntrySchema = Schema.Struct({ · 144 unmodified lines | ||
| 145 | 145 | cwd: Schema.optional(Schema.String), |
| 146 | 146 | env: Schema.optional(StringMap), |
| 147 | 147 | 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), | |
| 148 | 150 | }); |
| 149 | 151 | |
| 150 | 152 | const ListCodexPluginsResponse = Schema.Struct({ |
| @@ -41,7 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines | ||
| 41 | 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 44 | −import { CodexPluginsSection } from "./CodexPluginsSection"; | |
| 44 | +import CodexPluginAdd from "./CodexPluginAdd"; | |
| 45 | 45 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | 46 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 47 | 47 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| @@ -169,15 +169,14 @@ export default function AddMcpIntegration(props: { · 121 unmodified lines | ||
| 169 | 169 | // Drop stdio presets when stdio is disabled — the caller should have |
| 170 | 170 | // already filtered these out, but defence-in-depth. |
| 171 | 171 | 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. | |
| 176 | 175 | const isCodexPreset = isCodexPresetId(preset?.id); |
| 177 | 176 | const isStdioPreset = preset?.transport === "stdio" && !isCodexPreset; |
| 178 | 177 | |
| 179 | 178 | const [transport, setTransport] = useState<"remote" | "stdio">( |
| 180 | − (isStdioPreset || isCodexPreset) && allowStdio ? "stdio" : "remote", | |
| 179 | + isStdioPreset && allowStdio ? "stdio" : "remote", | |
| 181 | 180 | ); |
| 182 | 181 | |
| 183 | 182 | // --- Stdio state --- |
| @@ -398,6 +397,18 @@ export default function AddMcpIntegration(props: { · 214 unmodified lines | ||
| 398 | 397 | |
| 399 | 398 | // ---- Render ---- |
| 400 | 399 | |
| 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 | + | |
| 401 | 412 | return ( |
| 402 | 413 | <div className="flex flex-1 flex-col gap-6"> |
| 403 | 414 | <div> |
| @@ -525,13 +536,6 @@ export default function AddMcpIntegration(props: { · 121 unmodified lines | ||
| 525 | 536 | </> |
| 526 | 537 | ) : ( |
| 527 | 538 | <> |
| 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 | − | |
| 535 | 539 | {/* Stdio form */} |
| 536 | 540 | <CardStack> |
| 537 | 541 | <CardStackContent className="border-t-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 | +} | |
| @@ -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 | −} | |
| @@ -40,6 +40,9 @@ export interface CodexPluginEntry { · 39 unmodified lines | ||
| 40 | 40 | readonly env?: Readonly<Record<string, string>>; |
| 41 | 41 | /** Shown when `available` is false. */ |
| 42 | 42 | 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; | |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | 48 | /** The Codex Computer Use client binary — the stable, unversioned entry point |
| @@ -75,6 +78,7 @@ const PluginManifest = Schema.Struct({ · 29 unmodified lines | ||
| 75 | 78 | Schema.Struct({ |
| 76 | 79 | displayName: Schema.optional(Schema.String), |
| 77 | 80 | shortDescription: Schema.optional(Schema.String), |
| 81 | + logo: Schema.optional(Schema.String), | |
| 78 | 82 | }), |
| 79 | 83 | ), |
| 80 | 84 | description: Schema.optional(Schema.String), |
| @@ -159,6 +163,48 @@ const sanitizeId = (value: string): string => · 78 unmodified lines | ||
| 159 | 163 | .replace(/[^a-z0-9]+/g, "-") |
| 160 | 164 | .replace(/^-+|-+$/g, ""); |
| 161 | 165 | |
| 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 | + | |
| 162 | 208 | // --------------------------------------------------------------------------- |
| 163 | 209 | // Scan |
| 164 | 210 | // --------------------------------------------------------------------------- |
| @@ -192,6 +238,10 @@ const scanCachedPlugin = ( · 27 unmodified lines | ||
| 192 | 238 | manifest.interface?.shortDescription ?? |
| 193 | 239 | manifest.description?.split("\n")[0] ?? |
| 194 | 240 | `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)); | |
| 195 | 245 | |
| 196 | 246 | const localServers = Object.entries(servers.mcpServers).flatMap(([serverKey, spec]) => |
| 197 | 247 | spec.command !== undefined && spec.type !== "http" && spec.url === undefined |
| @@ -226,6 +276,7 @@ const scanCachedPlugin = ( · 28 unmodified lines | ||
| 226 | 276 | // the integration after adding it. |
| 227 | 277 | env: { CODEX_HOME: codexHome }, |
| 228 | 278 | ...(available ? {} : { setupHint: CODEX_SETUP_HINT }), |
| 279 | + ...(icon === undefined ? {} : { icon }), | |
| 229 | 280 | }; |
| 230 | 281 | }); |
| 231 | 282 | }; |
| @@ -248,19 +299,23 @@ export const scanCodexPlugins = (options?: { · 16 unmodified lines | ||
| 248 | 299 | const client = clientBinaryPath(codexHome); |
| 249 | 300 | const clientAvailable = isExecutableFile(client); |
| 250 | 301 | |
| 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 | + }); | |
| 264 | 319 | |
| 265 | 320 | const cacheDir = path.join(codexHome, "plugins", "cache"); |
| 266 | 321 | const scanned = listDirs(cacheDir).flatMap((sourceName) => { |
| @@ -28,14 +28,17 @@ export interface McpStdioPreset { · 27 unmodified lines | ||
| 28 | 28 | export type McpPreset = McpRemotePreset | McpStdioPreset; |
| 29 | 29 | |
| 30 | 30 | // 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. | |
| 35 | 37 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ |
| 36 | 38 | id: plugin.id, |
| 37 | 39 | name: plugin.name, |
| 38 | 40 | summary: plugin.summary, |
| 41 | + icon: "https://integrations.sh/logo/openai.com", | |
| 39 | 42 | family: "codex", |
| 40 | 43 | transport: "stdio", |
| 41 | 44 | command: "", |