History

Show a provider mark and stepwise setup for uninstalled Codex plugins

Rhys Sullivan committed 2 months ago

Commit ff496ec1 parent 571299a11 files · +92 −23Browse files

Unified · Split

11 files
  1. packages/core/sdk/src/client.ts +4 −0
  2. packages/core/sdk/src/plugin.ts +4 −0
  3. packages/plugins/mcp/src/react/CodexPluginAdd.tsx +17 −4
  4. packages/plugins/mcp/src/sdk/codex-plugin-presets.ts +34 −4
  5. packages/plugins/mcp/src/sdk/codex-plugins.test.ts +7 −1
  6. packages/plugins/mcp/src/sdk/codex-plugins.ts +3 −11
  7. packages/plugins/mcp/src/sdk/plugin.ts +1 −0
  8. packages/plugins/mcp/src/sdk/presets.ts +9 −0
  9. packages/react/src/components/command-palette.tsx +3 −0
  10. packages/react/src/components/preset-icon.tsx +9 −3
  11. packages/react/src/pages/integrations.tsx +1 −0
packages/core/sdk/src/client.ts+4−0
@@ -111,6 +111,10 @@ export interface IntegrationPreset { · 110 unmodified lines
111111 readonly endpoint?: string;
112112 /** Optional icon URL (favicon, logo). */
113113 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;
114118 /** Shown in the top-level grid on the integrations page when true. */
115119 readonly featured?: boolean;
116120 readonly family?: string;
packages/core/sdk/src/plugin.ts+4−0
@@ -596,6 +596,10 @@ export interface IntegrationPreset { · 595 unmodified lines
596596 readonly url?: string;
597597 readonly endpoint?: string;
598598 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;
599603 readonly featured?: boolean;
600604 readonly family?: string;
601605 readonly specFormat?: string;
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+17−4
@@ -94,9 +94,14 @@ export default function CodexPluginAdd(props: { · 93 unmodified lines
9494 {/* Mirrors the plugin's own page in Codex: its icon, display name,
9595 tagline, and long description, all read from the local install. */}
9696 <div className="flex flex-col gap-4">
97− {plugin.icon !== undefined && (
98− <img src={plugin.icon} alt="" className="size-16 rounded-2xl" />
99− )}
97+ {/* The plugin's own icon comes from the local Codex install; without
98+ one the card still identifies its provider rather than showing a
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+ />
100105 <div className="min-w-0">
101106 <div className="flex items-baseline gap-2">
102107 <h1 className="text-xl font-semibold text-foreground">
@@ -127,7 +132,15 @@ export default function CodexPluginAdd(props: { · 24 unmodified lines
127132 </span>
128133 </div>
129134 {!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>
131144 )}
132145 {plugin.available && !added && (
133146 <p className="text-[12px] text-muted-foreground">
packages/plugins/mcp/src/sdk/codex-plugin-presets.ts+34−4
@@ -32,11 +32,41 @@ export interface CuratedCodexPlugin { · 31 unmodified lines
3232 readonly summary: string;
3333 }
3434
35−export const CHROME_SETUP_HINT =
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.";
35+/** What to do when a card is not usable yet.
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+};
3764
38−export const CODEX_SETUP_HINT =
39− "Install the Codex app, sign in, and use this plugin once inside Codex so macOS grants its permissions (Full Disk Access, Contacts, Automation).";
65+/** The steps as one string, for the wire and for plain-text surfaces. */
66+export const setupHint = (requires: CuratedCodexPlugin["requires"], name: string): string =>
67+ setupSteps(requires, name)
68+ .map((step, index) => `${index + 1}. ${step}`)
69+ .join("\n");
4070
4171 export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [
4272 // Names are exactly the plugins' own displayNames — nothing invented, no
packages/plugins/mcp/src/sdk/codex-plugins.test.ts+7−1
@@ -174,7 +174,13 @@ describe("scanCodexPlugins", () => { · 173 unmodified lines
174174 expect(byId.get("codex-messages")?.available).toBe(false);
175175 expect(byId.get("codex-computer-use")?.available).toBe(false);
176176 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");
178184 // The docs server ships with Codex itself, so the CLI alone is enough.
179185 expect(byId.get("codex-openai-docs")?.available).toBe(true);
180186 });
packages/plugins/mcp/src/sdk/codex-plugins.ts+3−11
@@ -20,12 +20,7 @@ import * as path from "node:path"; · 19 unmodified lines
2020
2121 import { Option, Schema } from "effect";
2222
23−import {
24− CHROME_SETUP_HINT,
25− CODEX_SETUP_HINT,
26− CURATED_CODEX_PLUGINS,
27− type CuratedCodexPlugin,
28−} from "./codex-plugin-presets";
23+import { CURATED_CODEX_PLUGINS, setupHint, type CuratedCodexPlugin } from "./codex-plugin-presets";
2924
3025 export interface CodexPluginEntry {
3126 /** Stable card id, e.g. `codex-messages`. */
@@ -188,9 +183,6 @@ const readText = (file: string): string | undefined => · 156 unmodified lines
188183 const isReadableFile = (file: string): boolean =>
189184 tryOrElse(() => fs.statSync(file).isFile(), false);
190185
191−const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string =>
192− requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT;
193−
194186 const isExecutableFile = (file: string): boolean =>
195187 tryOrElse(() => {
196188 fs.accessSync(file, fs.constants.X_OK);
@@ -368,7 +360,7 @@ const scanCachedPlugin = ( · 171 unmodified lines
368360 // process's environment sight-unseen. A user can declare more env on
369361 // the integration after adding it.
370362 env: { CODEX_HOME: codexHome },
371− ...(available ? {} : { setupHint: CODEX_SETUP_HINT }),
363+ ...(available ? {} : { setupHint: setupHint("codex", displayName) }),
372364 ...display,
373365 };
374366 });
@@ -424,7 +416,7 @@ export const scanCodexPlugins = (options?: { · 49 unmodified lines
424416 ...(entry.surface === undefined ? {} : { surface: entry.surface }),
425417 ...(entry.surface === "browser" ? { modulePath: browserClient } : {}),
426418 },
427− ...(available ? {} : { setupHint: setupHintFor(entry.requires) }),
419+ ...(available ? {} : { setupHint: setupHint(entry.requires, entry.name) }),
428420 ...display,
429421 };
430422 });
packages/plugins/mcp/src/sdk/plugin.ts+1−0
@@ -852,6 +852,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 851 unmodified lines
852852 ...("url" in preset && preset.url ? { url: preset.url } : {}),
853853 ...("endpoint" in preset && preset.endpoint ? { endpoint: preset.endpoint } : {}),
854854 ...(preset.icon ? { icon: preset.icon } : {}),
855+ ...(preset.fallbackIcon ? { fallbackIcon: preset.fallbackIcon } : {}),
855856 ...(preset.featured ? { featured: preset.featured } : {}),
856857 ...(preset.family ? { family: preset.family } : {}),
857858 ...("defaultSlug" in preset && preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}),
packages/plugins/mcp/src/sdk/presets.ts+9−0
@@ -1,6 +1,8 @@
11 import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";
22
33 export interface McpRemotePreset {
4+ /** Image to show when `icon` cannot be resolved on this machine. */
5+ readonly fallbackIcon?: string;
46 readonly id: string;
57 readonly name: string;
68 readonly summary: string;
@@ -13,6 +15,8 @@ export interface McpRemotePreset { · 6 unmodified lines
1315 }
1416
1517 export interface McpStdioPreset {
18+ /** Image to show when `icon` cannot be resolved on this machine. */
19+ readonly fallbackIcon?: string;
1620 readonly id: string;
1721 readonly name: string;
1822 readonly summary: string;
@@ -41,6 +45,11 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 22 unmodified lines
4145 name: plugin.name,
4246 summary: plugin.summary,
4347 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",
4453 family: "codex",
4554 defaultSlug: plugin.slug,
4655 transport: "stdio",
packages/react/src/components/command-palette.tsx+3−0
@@ -72,6 +72,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 71 unmodified lines
7272 presetSummary?: string;
7373 presetUrl?: string;
7474 presetIcon?: string;
75+ presetFallbackIcon?: string;
7576 }> = [];
7677 for (const plugin of integrationPlugins) {
7778 for (const preset of plugin.presets ?? []) {
@@ -83,6 +84,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 5 unmodified lines
8384 presetSummary: preset.summary,
8485 presetUrl: preset.url,
8586 presetIcon: preset.icon,
87+ presetFallbackIcon: preset.fallbackIcon,
8688 });
8789 }
8890 }
@@ -196,6 +198,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 107 unmodified lines
196198 >
197199 <PresetIcon
198200 {...(e.presetIcon ? { icon: e.presetIcon } : {})}
201+ {...(e.presetFallbackIcon ? { fallbackSrc: e.presetFallbackIcon } : {})}
199202 className="size-4 shrink-0 object-contain"
200203 fallback={
201204 <span
packages/react/src/components/preset-icon.tsx+9−3
@@ -51,10 +51,16 @@ export const resolveExecutorIcon = (path: string): Promise<string | null> => { · 50 unmodified lines
5151 };
5252
5353 /** Renders a preset icon, resolving `executor:` scheme icons through the
54− * authenticated API. `fallback` shows while loading and when there is no
55− * icon. */
54+ * authenticated API.
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. */
5661 export function PresetIcon(props: {
5762 readonly icon?: string;
63+ readonly fallbackSrc?: string;
5864 readonly className?: string;
5965 readonly fallback?: React.ReactNode;
6066 }) {
@@ -72,7 +78,7 @@ export function PresetIcon(props: { · 11 unmodified lines
7278 };
7379 }, [isExecutorIcon, props.icon]);
7480
75− const src = isExecutorIcon ? fetched : (props.icon ?? null);
81+ const src = (isExecutorIcon ? fetched : (props.icon ?? null)) ?? props.fallbackSrc ?? null;
7682 if (src === null) return <>{props.fallback ?? null}</>;
7783 return <img src={src} alt="" className={props.className} loading="lazy" />;
7884 }
packages/react/src/pages/integrations.tsx+1−0
@@ -467,6 +467,7 @@ function PresetGrid(props: { · 466 unmodified lines
467467 <CardStackEntryMedia>
468468 <PresetIcon
469469 {...(preset.icon ? { icon: preset.icon } : {})}
470+ {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})}
470471 className="size-5 object-contain"
471472 fallback={
472473 <svg viewBox="0 0 16 16" className="size-3.5" fill="none">