Unified · Split
| @@ -145,6 +145,8 @@ const CodexPluginEntrySchema = Schema.Struct({ · 144 unmodified lines | |||
| 145 | cwd: Schema.optional(Schema.String), | 145 | cwd: Schema.optional(Schema.String), |
| 146 | env: Schema.optional(StringMap), | 146 | env: Schema.optional(StringMap), |
| 147 | setupHint: Schema.optional(Schema.String), | 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 | const ListCodexPluginsResponse = Schema.Struct({ | 152 | const ListCodexPluginsResponse = Schema.Struct({ |
| @@ -41,7 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines | |||
| 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 44 | import { CodexPluginsSection } from "./CodexPluginsSection"; | 44 | import CodexPluginAdd from "./CodexPluginAdd"; |
| 45 | import { parseStdioArgs } from "./stdio-fields"; | 45 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | import { isProbableMcpEndpoint } from "./probe-url"; | 46 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 47 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; | 47 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| @@ -169,15 +169,14 @@ export default function AddMcpIntegration(props: { · 121 unmodified lines | |||
| 169 | // Drop stdio presets when stdio is disabled — the caller should have | 169 | // Drop stdio presets when stdio is disabled — the caller should have |
| 170 | // already filtered these out, but defence-in-depth. | 170 | // already filtered these out, but defence-in-depth. |
| 171 | const preset = rawPreset?.transport === "stdio" && !allowStdio ? undefined : rawPreset; | 171 | const preset = rawPreset?.transport === "stdio" && !allowStdio ? undefined : rawPreset; |
| 172 | // A Codex plugin preset is a catalog pointer, not a spawn recipe: it opens | 172 | // A Codex plugin preset is a catalog pointer, not a spawn recipe: it gets |
| 173 | // the stdio tab and highlights the matching Codex-plugins card (which | 173 | // its own focused add screen (rendered below, before the generic form), |
| 174 | // carries the server-resolved command and availability) instead of | 174 | // fed by the server-side scanner. |
| 175 | // prefilling the manual form. | ||
| 176 | const isCodexPreset = isCodexPresetId(preset?.id); | 175 | const isCodexPreset = isCodexPresetId(preset?.id); |
| 177 | const isStdioPreset = preset?.transport === "stdio" && !isCodexPreset; | 176 | const isStdioPreset = preset?.transport === "stdio" && !isCodexPreset; |
| 178 | 177 | ||
| 179 | const [transport, setTransport] = useState<"remote" | "stdio">( | 178 | const [transport, setTransport] = useState<"remote" | "stdio">( |
| 180 | (isStdioPreset || isCodexPreset) && allowStdio ? "stdio" : "remote", | 179 | isStdioPreset && allowStdio ? "stdio" : "remote", |
| 181 | ); | 180 | ); |
| 182 | 181 | ||
| 183 | // --- Stdio state --- | 182 | // --- Stdio state --- |
| @@ -398,6 +397,18 @@ export default function AddMcpIntegration(props: { · 214 unmodified lines | |||
| 398 | 397 | ||
| 399 | // ---- Render ---- | 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 | return ( | 412 | return ( |
| 402 | <div className="flex flex-1 flex-col gap-6"> | 413 | <div className="flex flex-1 flex-col gap-6"> |
| 403 | <div> | 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 | {/* Stdio form */} | 539 | {/* Stdio form */} |
| 536 | <CardStack> | 540 | <CardStack> |
| 537 | <CardStackContent className="border-t-0"> | 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 | readonly env?: Readonly<Record<string, string>>; | 40 | readonly env?: Readonly<Record<string, string>>; |
| 41 | /** Shown when `available` is false. */ | 41 | /** Shown when `available` is false. */ |
| 42 | readonly setupHint?: string; | 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 | /** The Codex Computer Use client binary — the stable, unversioned entry point | 48 | /** The Codex Computer Use client binary — the stable, unversioned entry point |
| @@ -75,6 +78,7 @@ const PluginManifest = Schema.Struct({ · 29 unmodified lines | |||
| 75 | Schema.Struct({ | 78 | Schema.Struct({ |
| 76 | displayName: Schema.optional(Schema.String), | 79 | displayName: Schema.optional(Schema.String), |
| 77 | shortDescription: Schema.optional(Schema.String), | 80 | shortDescription: Schema.optional(Schema.String), |
| 81 | logo: Schema.optional(Schema.String), | ||
| 78 | }), | 82 | }), |
| 79 | ), | 83 | ), |
| 80 | description: Schema.optional(Schema.String), | 84 | description: Schema.optional(Schema.String), |
| @@ -159,6 +163,48 @@ const sanitizeId = (value: string): string => · 78 unmodified lines | |||
| 159 | .replace(/[^a-z0-9]+/g, "-") | 163 | .replace(/[^a-z0-9]+/g, "-") |
| 160 | .replace(/^-+|-+$/g, ""); | 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 | // Scan | 209 | // Scan |
| 164 | // --------------------------------------------------------------------------- | 210 | // --------------------------------------------------------------------------- |
| @@ -192,6 +238,10 @@ const scanCachedPlugin = ( · 27 unmodified lines | |||
| 192 | manifest.interface?.shortDescription ?? | 238 | manifest.interface?.shortDescription ?? |
| 193 | manifest.description?.split("\n")[0] ?? | 239 | manifest.description?.split("\n")[0] ?? |
| 194 | `Local MCP server from the Codex plugin "${manifest.name}".`; | 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 | const localServers = Object.entries(servers.mcpServers).flatMap(([serverKey, spec]) => | 246 | const localServers = Object.entries(servers.mcpServers).flatMap(([serverKey, spec]) => |
| 197 | spec.command !== undefined && spec.type !== "http" && spec.url === undefined | 247 | spec.command !== undefined && spec.type !== "http" && spec.url === undefined |
| @@ -226,6 +276,7 @@ const scanCachedPlugin = ( · 28 unmodified lines | |||
| 226 | // the integration after adding it. | 276 | // the integration after adding it. |
| 227 | env: { CODEX_HOME: codexHome }, | 277 | env: { CODEX_HOME: codexHome }, |
| 228 | ...(available ? {} : { setupHint: CODEX_SETUP_HINT }), | 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 | const client = clientBinaryPath(codexHome); | 299 | const client = clientBinaryPath(codexHome); |
| 249 | const clientAvailable = isExecutableFile(client); | 300 | const clientAvailable = isExecutableFile(client); |
| 250 | 301 | ||
| 251 | const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => ({ | 302 | const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => { |
| 252 | id: entry.id, | 303 | const icon = curatedIconDataUri(codexHome, entry.pluginName); |
| 253 | name: entry.name, | 304 | return { |
| 254 | summary: entry.summary, | 305 | id: entry.id, |
| 255 | available: clientAvailable, | 306 | name: entry.name, |
| 256 | slug: entry.slug, | 307 | summary: entry.summary, |
| 257 | source: "curated" as const, | 308 | available: clientAvailable, |
| 258 | command: client, | 309 | slug: entry.slug, |
| 259 | args: entry.args, | 310 | source: "curated" as const, |
| 260 | cwd: path.join(codexHome, "computer-use"), | 311 | command: client, |
| 261 | env: { CODEX_HOME: codexHome }, | 312 | args: entry.args, |
| 262 | ...(clientAvailable ? {} : { setupHint: CODEX_SETUP_HINT }), | 313 | cwd: path.join(codexHome, "computer-use"), |
| 263 | })); | 314 | env: { CODEX_HOME: codexHome }, |
| 315 | ...(clientAvailable ? {} : { setupHint: CODEX_SETUP_HINT }), | ||
| 316 | ...(icon === undefined ? {} : { icon }), | ||
| 317 | }; | ||
| 318 | }); | ||
| 264 | 319 | ||
| 265 | const cacheDir = path.join(codexHome, "plugins", "cache"); | 320 | const cacheDir = path.join(codexHome, "plugins", "cache"); |
| 266 | const scanned = listDirs(cacheDir).flatMap((sourceName) => { | 321 | const scanned = listDirs(cacheDir).flatMap((sourceName) => { |
| @@ -28,14 +28,17 @@ export interface McpStdioPreset { · 27 unmodified lines | |||
| 28 | export type McpPreset = McpRemotePreset | McpStdioPreset; | 28 | export type McpPreset = McpRemotePreset | McpStdioPreset; |
| 29 | 29 | ||
| 30 | // Codex plugin presets — searchable catalog entries ("imessage", "computer | 30 | // Codex plugin presets — searchable catalog entries ("imessage", "computer |
| 31 | // use", …) for the plugins the add form's Codex-plugins section installs. | 31 | // use", …). `command` is deliberately empty: the real spawn recipe is |
| 32 | // `command` is deliberately empty: the real spawn recipe is machine-specific | 32 | // machine-specific and comes from the server-side scanner |
| 33 | // and comes from the server-side scanner (`codex-plugins.ts`); picking one of | 33 | // (`codex-plugins.ts`); picking one of these opens the focused Codex add |
| 34 | // these routes to the stdio tab with the matching card highlighted. | 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 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ | 37 | const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map((plugin) => ({ |
| 36 | id: plugin.id, | 38 | id: plugin.id, |
| 37 | name: plugin.name, | 39 | name: plugin.name, |
| 38 | summary: plugin.summary, | 40 | summary: plugin.summary, |
| 41 | icon: "https://integrations.sh/logo/openai.com", | ||
| 39 | family: "codex", | 42 | family: "codex", |
| 40 | transport: "stdio", | 43 | transport: "stdio", |
| 41 | command: "", | 44 | command: "", |