Unified · Split
| @@ -111,6 +111,10 @@ export interface IntegrationPreset { · 110 unmodified lines | |||
| 111 | readonly endpoint?: string; | 111 | readonly endpoint?: string; |
| 112 | /** Optional icon URL (favicon, logo). */ | 112 | /** Optional icon URL (favicon, logo). */ |
| 113 | readonly icon?: string; | 113 | readonly icon?: string; |
| 114 | /** Image to show when `icon` cannot be resolved on this machine — a preset | ||
| 115 | * whose icon is read from a local install has none until that install | ||
| 116 | * exists, which is exactly when the card most needs to identify itself. */ | ||
| 117 | readonly fallbackIcon?: string; | ||
| 114 | /** Shown in the top-level grid on the integrations page when true. */ | 118 | /** Shown in the top-level grid on the integrations page when true. */ |
| 115 | readonly featured?: boolean; | 119 | readonly featured?: boolean; |
| 116 | readonly family?: string; | 120 | readonly family?: string; |
| @@ -596,6 +596,10 @@ export interface IntegrationPreset { · 595 unmodified lines | |||
| 596 | readonly url?: string; | 596 | readonly url?: string; |
| 597 | readonly endpoint?: string; | 597 | readonly endpoint?: string; |
| 598 | readonly icon?: string; | 598 | readonly icon?: string; |
| 599 | /** Image to show when `icon` cannot be resolved on this machine — a preset | ||
| 600 | * whose icon is read from a local install has none until that install | ||
| 601 | * exists, which is exactly when the card most needs to identify itself. */ | ||
| 602 | readonly fallbackIcon?: string; | ||
| 599 | readonly featured?: boolean; | 603 | readonly featured?: boolean; |
| 600 | readonly family?: string; | 604 | readonly family?: string; |
| 601 | readonly specFormat?: string; | 605 | readonly specFormat?: string; |
| @@ -94,9 +94,14 @@ export default function CodexPluginAdd(props: { · 93 unmodified lines | |||
| 94 | {/* Mirrors the plugin's own page in Codex: its icon, display name, | 94 | {/* Mirrors the plugin's own page in Codex: its icon, display name, |
| 95 | tagline, and long description, all read from the local install. */} | 95 | tagline, and long description, all read from the local install. */} |
| 96 | <div className="flex flex-col gap-4"> | 96 | <div className="flex flex-col gap-4"> |
| 97 | {plugin.icon !== undefined && ( | 97 | {/* The plugin's own icon comes from the local Codex install; without |
| 98 | <img src={plugin.icon} alt="" className="size-16 rounded-2xl" /> | 98 | one the card still identifies its provider rather than showing a |
| 99 | )} | 99 | gap, which matters most on the machines that have no install. */} |
| 100 | <img | ||
| 101 | src={plugin.icon ?? "https://integrations.sh/logo/openai.com"} | ||
| 102 | alt="" | ||
| 103 | className="size-16 rounded-2xl" | ||
| 104 | /> | ||
| 100 | <div className="min-w-0"> | 105 | <div className="min-w-0"> |
| 101 | <div className="flex items-baseline gap-2"> | 106 | <div className="flex items-baseline gap-2"> |
| 102 | <h1 className="text-xl font-semibold text-foreground"> | 107 | <h1 className="text-xl font-semibold text-foreground"> |
| @@ -127,7 +132,15 @@ export default function CodexPluginAdd(props: { · 24 unmodified lines | |||
| 127 | </span> | 132 | </span> |
| 128 | </div> | 133 | </div> |
| 129 | {!plugin.available && plugin.setupHint !== undefined && ( | 134 | {!plugin.available && plugin.setupHint !== undefined && ( |
| 130 | <p className="text-[12px] text-muted-foreground">{plugin.setupHint}</p> | 135 | // The hint arrives as numbered lines; render them as the list they |
| 136 | // are, so the reader sees an ordered path rather than a paragraph. | ||
| 137 | <ol className="mt-1 flex list-none flex-col gap-1.5"> | ||
| 138 | {plugin.setupHint.split("\n").map((step) => ( | ||
| 139 | <li key={step} className="text-[12px] leading-relaxed text-muted-foreground"> | ||
| 140 | {step} | ||
| 141 | </li> | ||
| 142 | ))} | ||
| 143 | </ol> | ||
| 131 | )} | 144 | )} |
| 132 | {plugin.available && !added && ( | 145 | {plugin.available && !added && ( |
| 133 | <p className="text-[12px] text-muted-foreground"> | 146 | <p className="text-[12px] text-muted-foreground"> |
| @@ -32,11 +32,41 @@ export interface CuratedCodexPlugin { · 31 unmodified lines | |||
| 32 | readonly summary: string; | 32 | readonly summary: string; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | export const CHROME_SETUP_HINT = | 35 | /** What to do when a card is not usable yet. |
| 36 | "Install the Codex app and add the Chrome plugin (Settings \u2192 Computer use installs the ChatGPT browser extension), then use it once inside Codex so it can reach your browser."; | 36 | * |
| 37 | * Written as numbered steps rather than one sentence: a person reading this | ||
| 38 | * has just been told they cannot proceed, and the useful answer is the | ||
| 39 | * shortest ordered path to being able to. Each step names where to go, and | ||
| 40 | * the last one says to come back — otherwise the card is a dead end. The | ||
| 41 | * plugin's own name is substituted in, so the instruction is about the thing | ||
| 42 | * the person clicked rather than about plugins in general. */ | ||
| 43 | export const setupSteps = ( | ||
| 44 | requires: CuratedCodexPlugin["requires"], | ||
| 45 | name: string, | ||
| 46 | ): readonly string[] => { | ||
| 47 | const install = "Install the Codex app from openai.com/codex, then sign in."; | ||
| 48 | const finish = "Come back here and add it."; | ||
| 49 | if (requires === "codex") return [install, finish]; | ||
| 50 | if (requires === "chrome-plugin") { | ||
| 51 | return [ | ||
| 52 | install, | ||
| 53 | "In Codex, open Settings \u2192 Computer use and install the ChatGPT browser extension.", | ||
| 54 | "Use Chrome once inside Codex, so it can reach your browser.", | ||
| 55 | finish, | ||
| 56 | ]; | ||
| 57 | } | ||
| 58 | return [ | ||
| 59 | install, | ||
| 60 | `Open ${name} once inside Codex. macOS asks for its permissions the first time \u2014 Full Disk Access, Contacts, and Automation.`, | ||
| 61 | finish, | ||
| 62 | ]; | ||
| 63 | }; | ||
| 37 | 64 | ||
| 38 | export const CODEX_SETUP_HINT = | 65 | /** The steps as one string, for the wire and for plain-text surfaces. */ |
| 39 | "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation)."; | 66 | export const setupHint = (requires: CuratedCodexPlugin["requires"], name: string): string => |
| 67 | setupSteps(requires, name) | ||
| 68 | .map((step, index) => `${index + 1}. ${step}`) | ||
| 69 | .join("\n"); | ||
| 40 | 70 | ||
| 41 | export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ | 71 | export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ |
| 42 | // Names are exactly the plugins' own displayNames — nothing invented, no | 72 | // Names are exactly the plugins' own displayNames — nothing invented, no |
| @@ -174,7 +174,13 @@ describe("scanCodexPlugins", () => { · 173 unmodified lines | |||
| 174 | expect(byId.get("codex-messages")?.available).toBe(false); | 174 | expect(byId.get("codex-messages")?.available).toBe(false); |
| 175 | expect(byId.get("codex-computer-use")?.available).toBe(false); | 175 | expect(byId.get("codex-computer-use")?.available).toBe(false); |
| 176 | expect(byId.get("codex-chrome")?.available).toBe(false); | 176 | expect(byId.get("codex-chrome")?.available).toBe(false); |
| 177 | expect(byId.get("codex-chrome")?.setupHint).toContain("Chrome plugin"); | 177 | // Each card's steps are about ITS requirement: Chrome needs the browser |
| 178 | // extension, which no other card mentions. | ||
| 179 | expect(byId.get("codex-chrome")?.setupHint).toContain("browser extension"); | ||
| 180 | expect(byId.get("codex-messages")?.setupHint).not.toContain("browser extension"); | ||
| 181 | // And the steps are numbered and name the plugin the person clicked. | ||
| 182 | expect(byId.get("codex-messages")?.setupHint).toContain("1. Install the Codex app"); | ||
| 183 | expect(byId.get("codex-messages")?.setupHint).toContain("Open Messages once inside Codex"); | ||
| 178 | // The docs server ships with Codex itself, so the CLI alone is enough. | 184 | // The docs server ships with Codex itself, so the CLI alone is enough. |
| 179 | expect(byId.get("codex-openai-docs")?.available).toBe(true); | 185 | expect(byId.get("codex-openai-docs")?.available).toBe(true); |
| 180 | }); | 186 | }); |
| @@ -20,12 +20,7 @@ import * as path from "node:path"; · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | import { Option, Schema } from "effect"; | 21 | import { Option, Schema } from "effect"; |
| 22 | 22 | ||
| 23 | import { | 23 | import { CURATED_CODEX_PLUGINS, setupHint, type CuratedCodexPlugin } from "./codex-plugin-presets"; |
| 24 | CHROME_SETUP_HINT, | ||
| 25 | CODEX_SETUP_HINT, | ||
| 26 | CURATED_CODEX_PLUGINS, | ||
| 27 | type CuratedCodexPlugin, | ||
| 28 | } from "./codex-plugin-presets"; | ||
| 29 | 24 | ||
| 30 | export interface CodexPluginEntry { | 25 | export interface CodexPluginEntry { |
| 31 | /** Stable card id, e.g. `codex-messages`. */ | 26 | /** Stable card id, e.g. `codex-messages`. */ |
| @@ -188,9 +183,6 @@ const readText = (file: string): string | undefined => · 156 unmodified lines | |||
| 188 | const isReadableFile = (file: string): boolean => | 183 | const isReadableFile = (file: string): boolean => |
| 189 | tryOrElse(() => fs.statSync(file).isFile(), false); | 184 | tryOrElse(() => fs.statSync(file).isFile(), false); |
| 190 | 185 | ||
| 191 | const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string => | ||
| 192 | requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT; | ||
| 193 | |||
| 194 | const isExecutableFile = (file: string): boolean => | 186 | const isExecutableFile = (file: string): boolean => |
| 195 | tryOrElse(() => { | 187 | tryOrElse(() => { |
| 196 | fs.accessSync(file, fs.constants.X_OK); | 188 | fs.accessSync(file, fs.constants.X_OK); |
| @@ -368,7 +360,7 @@ const scanCachedPlugin = ( · 171 unmodified lines | |||
| 368 | // process's environment sight-unseen. A user can declare more env on | 360 | // process's environment sight-unseen. A user can declare more env on |
| 369 | // the integration after adding it. | 361 | // the integration after adding it. |
| 370 | env: { CODEX_HOME: codexHome }, | 362 | env: { CODEX_HOME: codexHome }, |
| 371 | ...(available ? {} : { setupHint: CODEX_SETUP_HINT }), | 363 | ...(available ? {} : { setupHint: setupHint("codex", displayName) }), |
| 372 | ...display, | 364 | ...display, |
| 373 | }; | 365 | }; |
| 374 | }); | 366 | }); |
| @@ -424,7 +416,7 @@ export const scanCodexPlugins = (options?: { · 49 unmodified lines | |||
| 424 | ...(entry.surface === undefined ? {} : { surface: entry.surface }), | 416 | ...(entry.surface === undefined ? {} : { surface: entry.surface }), |
| 425 | ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), | 417 | ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), |
| 426 | }, | 418 | }, |
| 427 | ...(available ? {} : { setupHint: setupHintFor(entry.requires) }), | 419 | ...(available ? {} : { setupHint: setupHint(entry.requires, entry.name) }), |
| 428 | ...display, | 420 | ...display, |
| 429 | }; | 421 | }; |
| 430 | }); | 422 | }); |
| @@ -852,6 +852,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 851 unmodified lines | |||
| 852 | ...("url" in preset && preset.url ? { url: preset.url } : {}), | 852 | ...("url" in preset && preset.url ? { url: preset.url } : {}), |
| 853 | ...("endpoint" in preset && preset.endpoint ? { endpoint: preset.endpoint } : {}), | 853 | ...("endpoint" in preset && preset.endpoint ? { endpoint: preset.endpoint } : {}), |
| 854 | ...(preset.icon ? { icon: preset.icon } : {}), | 854 | ...(preset.icon ? { icon: preset.icon } : {}), |
| 855 | ...(preset.fallbackIcon ? { fallbackIcon: preset.fallbackIcon } : {}), | ||
| 855 | ...(preset.featured ? { featured: preset.featured } : {}), | 856 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 856 | ...(preset.family ? { family: preset.family } : {}), | 857 | ...(preset.family ? { family: preset.family } : {}), |
| 857 | ...("defaultSlug" in preset && preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}), | 858 | ...("defaultSlug" in preset && preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}), |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets"; | 1 | import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets"; |
| 2 | 2 | ||
| 3 | export interface McpRemotePreset { | 3 | export interface McpRemotePreset { |
| 4 | /** Image to show when `icon` cannot be resolved on this machine. */ | ||
| 5 | readonly fallbackIcon?: string; | ||
| 4 | readonly id: string; | 6 | readonly id: string; |
| 5 | readonly name: string; | 7 | readonly name: string; |
| 6 | readonly summary: string; | 8 | readonly summary: string; |
| @@ -13,6 +15,8 @@ export interface McpRemotePreset { · 6 unmodified lines | |||
| 13 | } | 15 | } |
| 14 | 16 | ||
| 15 | export interface McpStdioPreset { | 17 | export interface McpStdioPreset { |
| 18 | /** Image to show when `icon` cannot be resolved on this machine. */ | ||
| 19 | readonly fallbackIcon?: string; | ||
| 16 | readonly id: string; | 20 | readonly id: string; |
| 17 | readonly name: string; | 21 | readonly name: string; |
| 18 | readonly summary: string; | 22 | readonly summary: string; |
| @@ -41,6 +45,11 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 22 unmodified lines | |||
| 41 | name: plugin.name, | 45 | name: plugin.name, |
| 42 | summary: plugin.summary, | 46 | summary: plugin.summary, |
| 43 | icon: `executor:/mcp/codex-plugins/${plugin.id}/icon`, | 47 | icon: `executor:/mcp/codex-plugins/${plugin.id}/icon`, |
| 48 | // The plugin's own icon lives in the user's Codex install, so a machine | ||
| 49 | // without Codex has none to read. Fall back to the provider's mark from the | ||
| 50 | // same logo service every other preset uses, rather than vendoring OpenAI's | ||
| 51 | // artwork into this repo. | ||
| 52 | fallbackIcon: "https://integrations.sh/logo/openai.com", | ||
| 44 | family: "codex", | 53 | family: "codex", |
| 45 | defaultSlug: plugin.slug, | 54 | defaultSlug: plugin.slug, |
| 46 | transport: "stdio", | 55 | transport: "stdio", |
| @@ -72,6 +72,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 71 unmodified lines | |||
| 72 | presetSummary?: string; | 72 | presetSummary?: string; |
| 73 | presetUrl?: string; | 73 | presetUrl?: string; |
| 74 | presetIcon?: string; | 74 | presetIcon?: string; |
| 75 | presetFallbackIcon?: string; | ||
| 75 | }> = []; | 76 | }> = []; |
| 76 | for (const plugin of integrationPlugins) { | 77 | for (const plugin of integrationPlugins) { |
| 77 | for (const preset of plugin.presets ?? []) { | 78 | for (const preset of plugin.presets ?? []) { |
| @@ -83,6 +84,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 5 unmodified lines | |||
| 83 | presetSummary: preset.summary, | 84 | presetSummary: preset.summary, |
| 84 | presetUrl: preset.url, | 85 | presetUrl: preset.url, |
| 85 | presetIcon: preset.icon, | 86 | presetIcon: preset.icon, |
| 87 | presetFallbackIcon: preset.fallbackIcon, | ||
| 86 | }); | 88 | }); |
| 87 | } | 89 | } |
| 88 | } | 90 | } |
| @@ -196,6 +198,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 107 unmodified lines | |||
| 196 | > | 198 | > |
| 197 | <PresetIcon | 199 | <PresetIcon |
| 198 | {...(e.presetIcon ? { icon: e.presetIcon } : {})} | 200 | {...(e.presetIcon ? { icon: e.presetIcon } : {})} |
| 201 | {...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})} | ||
| 199 | className="size-4 shrink-0 object-contain" | 202 | className="size-4 shrink-0 object-contain" |
| 200 | fallback={ | 203 | fallback={ |
| 201 | <span | 204 | <span |
| @@ -51,10 +51,16 @@ export const resolveExecutorIcon = (path: string): Promise<string | null> => { · 50 unmodified lines | |||
| 51 | }; | 51 | }; |
| 52 | 52 | ||
| 53 | /** Renders a preset icon, resolving `executor:` scheme icons through the | 53 | /** Renders a preset icon, resolving `executor:` scheme icons through the |
| 54 | * authenticated API. `fallback` shows while loading and when there is no | 54 | * authenticated API. |
| 55 | * icon. */ | 55 | * |
| 56 | * `fallbackSrc` is a plain image URL to use when the machine-local icon is | ||
| 57 | * unavailable — a Codex plugin card still shows its provider's mark on a | ||
| 58 | * machine where Codex is not installed, which is exactly when the card is | ||
| 59 | * most in need of explaining itself. `fallback` is the last resort, for when | ||
| 60 | * there is no image of any kind. */ | ||
| 56 | export function PresetIcon(props: { | 61 | export function PresetIcon(props: { |
| 57 | readonly icon?: string; | 62 | readonly icon?: string; |
| 63 | readonly fallbackSrc?: string; | ||
| 58 | readonly className?: string; | 64 | readonly className?: string; |
| 59 | readonly fallback?: React.ReactNode; | 65 | readonly fallback?: React.ReactNode; |
| 60 | }) { | 66 | }) { |
| @@ -72,7 +78,7 @@ export function PresetIcon(props: { · 11 unmodified lines | |||
| 72 | }; | 78 | }; |
| 73 | }, [isExecutorIcon, props.icon]); | 79 | }, [isExecutorIcon, props.icon]); |
| 74 | 80 | ||
| 75 | const src = isExecutorIcon ? fetched : (props.icon ?? null); | 81 | const src = (isExecutorIcon ? fetched : (props.icon ?? null)) ?? props.fallbackSrc ?? null; |
| 76 | if (src === null) return <>{props.fallback ?? null}</>; | 82 | if (src === null) return <>{props.fallback ?? null}</>; |
| 77 | return <img src={src} alt="" className={props.className} loading="lazy" />; | 83 | return <img src={src} alt="" className={props.className} loading="lazy" />; |
| 78 | } | 84 | } |
| @@ -467,6 +467,7 @@ function PresetGrid(props: { · 466 unmodified lines | |||
| 467 | <CardStackEntryMedia> | 467 | <CardStackEntryMedia> |
| 468 | <PresetIcon | 468 | <PresetIcon |
| 469 | {...(preset.icon ? { icon: preset.icon } : {})} | 469 | {...(preset.icon ? { icon: preset.icon } : {})} |
| 470 | {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})} | ||
| 470 | className="size-5 object-contain" | 471 | className="size-5 object-contain" |
| 471 | fallback={ | 472 | fallback={ |
| 472 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | 473 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> |