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
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;
packages/core/sdk/src/plugin.ts+4−0
@@ -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;
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+17−4
@@ -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">
packages/plugins/mcp/src/sdk/codex-plugin-presets.ts+34−4
@@ -32,11 +32,41 @@ export interface CuratedCodexPlugin { · 31 unmodified lines
32 readonly summary: string;32 readonly summary: string;
33}33}
34 34
35export 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. */
43export 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
38export 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).";66export const setupHint = (requires: CuratedCodexPlugin["requires"], name: string): string =>
67 setupSteps(requires, name)
68 .map((step, index) => `${index + 1}. ${step}`)
69 .join("\n");
40 70
41export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [71export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [
42 // Names are exactly the plugins' own displayNames — nothing invented, no72 // 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
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 });
packages/plugins/mcp/src/sdk/codex-plugins.ts+3−11
@@ -20,12 +20,7 @@ import * as path from "node:path"; · 19 unmodified lines
20 20
21import { Option, Schema } from "effect";21import { Option, Schema } from "effect";
22 22
23import {23import { 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
30export interface CodexPluginEntry {25export 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
188const isReadableFile = (file: string): boolean =>183const isReadableFile = (file: string): boolean =>
189 tryOrElse(() => fs.statSync(file).isFile(), false);184 tryOrElse(() => fs.statSync(file).isFile(), false);
190 185
191const setupHintFor = (requires: CuratedCodexPlugin["requires"]): string =>
192 requires === "chrome-plugin" ? CHROME_SETUP_HINT : CODEX_SETUP_HINT;
193
194const isExecutableFile = (file: string): boolean =>186const 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 on360 // 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 });
packages/plugins/mcp/src/sdk/plugin.ts+1−0
@@ -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 } : {}),
packages/plugins/mcp/src/sdk/presets.ts+9−0
@@ -1,6 +1,8 @@
1import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";1import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";
2 2
3export interface McpRemotePreset {3export 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
15export interface McpStdioPreset {17export 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",
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
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 <PresetIcon199 <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 <span204 <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
51};51};
52 52
53/** Renders a preset icon, resolving `executor:` scheme icons through the53/** Renders a preset icon, resolving `executor:` scheme icons through the
54 * authenticated API. `fallback` shows while loading and when there is no54 * 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. */
56export function PresetIcon(props: {61export 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}
packages/react/src/pages/integrations.tsx+1−0
@@ -467,6 +467,7 @@ function PresetGrid(props: { · 466 unmodified lines
467 <CardStackEntryMedia>467 <CardStackEntryMedia>
468 <PresetIcon468 <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">