Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ Configure paths in `release-please-config.json`. The workflow lives at
Each release also gets `arcade-chatgpt-{VERSION}.zip`, the package to upload
in OpenAI's plugin portal for the ChatGPT and Codex directory. It holds the
portable plugin only (`plugin.json` at the ZIP root, `mcp.json`, `skills/`,
the logo, and `LICENSE`), with no hooks or other clients' folders. Build it
the listing images, and `LICENSE`), with no hooks or other clients' folders. Build it
locally with `npm run package:chatgpt` in `dev/` (writes `dist/` at the repo
root); the build fails if the `extensions.com.openai.interface` listing breaks
the portal's limits, such as the 30-character display name and subtitle
Expand Down
98 changes: 54 additions & 44 deletions dev/scripts/generate-manifests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,20 @@ const inPlugin = (file) => `${PLUGIN_DIR}/${file}`;

// One policy URL for both clients. The other OpenAI directory fields below are
// written into plugin/plugin.json from here too. Identity text (name, descriptions,
// category, website, logo) stays in plugin/plugin.json.
// category, website) stays in plugin/plugin.json. Listing images and brandColor
// are written from the constants below. Cursor's manifest takes only `logo`
// (without the "./" prefix). Claude has no logo field. Dark variants and
// brandColor stay on the OpenAI listing; the other clients' schemas have no
// fields for them.
const privacyPolicyUrl = "https://www.arcade.dev/privacy-policy/";
const openaiSupportUrl = "https://support.arcade.dev";
const openaiTermsUrl = "https://www.arcade.dev/terms-of-service/";
const OPENAI_LOGO = "./assets/logo.png";
const OPENAI_LOGO_DARK = "./assets/logo-dark.png";
const OPENAI_COMPOSER_ICON = "./assets/composer-icon.png";
const OPENAI_COMPOSER_ICON_DARK = "./assets/composer-icon-dark.png";
const OPENAI_BRAND_COLOR = "#4708F9";
const OPENAI_IMAGE_KEYS = ["logo", "logoDark", "composerIcon", "composerIconDark", "brandColor", "brandColorDark"];

const OPENAI_CAPABILITIES = [
"Read and search email",
Expand All @@ -43,60 +53,62 @@ const OPENAI_DEFAULT_PROMPTS = [
"Which GitHub issues are assigned to me?",
];

// Reviewers sign up for their own free Arcade account, so every case runs on
// tools that need no app sign-in and no API key: the gateway's own
// Arcade_ListApps, and the Math toolkit through Arcade_SelectTools then
// Arcade_UseTool. tools_triggered names the gateway tools OpenAI's MCP scan
// sees, then the Math tool Arcade_UseTool runs, in Toolkit.ToolName form.
// Reviewers sign up for their own free Arcade account. Positive prompts call
// Arcade_SelectTools, then Arcade_UseTool for the toolkit tool named in
// parentheses (Toolkit.ToolName). Flights, Finance, and YouTube need no user
// OAuth; fares, prices, and video lists change, so expected_behavior checks
// the shape of the result. The Math average is the one fixed number. Negative
// prompts do not mention Arcade, so the app should not be called. Positive
// descriptions stay within OpenAI's 4000-character limit.
// demo_recording_url is a real review field and is required before MCP review,
// but a ZIP may omit it. No recording URL is published here; omitting the field
// leaves any value already saved in the dashboard.
const OPENAI_REVIEW = {
test_cases: {
positive: [
{
description: "List the apps Arcade offers and which ones are connected",
prompt: "Use Arcade to list which apps I can use, and which are connected.",
tools_triggered: "Arcade_ListApps",
expected_behavior: "Lists the apps available through Arcade and whether each one is connected. A new account shows most apps as not connected. Nothing is connected, disconnected, or changed.",
description: "Find trip-planning tools without running them",
prompt: "Use Arcade to find tools that could help me plan a weekend trip to Chicago: flights, hotels and driving directions. Just list what's available, don't run anything yet.",
tools_triggered: "Arcade_SelectTools",
expected_behavior: "Lists tools for flights, hotels, and driving directions, with a short description of each, and runs none of them. Returns no account names, emails, or connection status. Which tools are offered can vary with the catalog.",
},
{
description: "Average a list of amounts",
prompt: "Use Arcade to average my monthly software bills: $1,249.50, $389.99, $72.25 and $418.26.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Math.Avg)",
expected_behavior: "Answers $532.50.",
description: "Find a one-way fare and total it for 3 travelers",
prompt: "Use Arcade to find the cheapest one-way economy flight from SFO to JFK two weeks from today, then use Arcade's Math tools to calculate the total fare for 3 travelers.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (GoogleFlights.SearchFlights), Arcade_UseTool (Math.Multiply)",
expected_behavior: "Reports the cheapest one-way economy fare it finds from SFO to JFK about two weeks out, with airline, flight number, and times, then the total for 3 travelers. Fares change; the total should equal the reported fare times 3. Nothing is booked.",
},
{
description: "Raise a number to a power for compound growth",
prompt: "Use Arcade to calculate 1.05 to the 10th power, for 10 years of 5% annual growth.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Math.Power)",
expected_behavior: "Answers about 1.6289 (1.62889462677744140625), meaning about 62.9% total growth over 10 years.",
description: "Get the Google Finance summary for NVDA",
prompt: "Use Arcade to get the Google Finance stock summary for NVDA.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (GoogleFinance.GetStockSummary)",
expected_behavior: "Returns the Google Finance summary for NVDA, including NVIDIA's latest price in USD and the day's move in value and percent. Prices change; check the ticker and those fields. No order is placed.",
},
{
description: "Take a square root",
prompt: "Use Arcade to calculate the square root of 7,569.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Math.Sqrt)",
expected_behavior: "Answers 87.",
description: "Search YouTube for videos about MCP",
prompt: "Use Arcade to search YouTube for videos explaining the Model Context Protocol.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Youtube.SearchForVideos)",
expected_behavior: "Returns YouTube videos about the Model Context Protocol, each with a title, channel, and link. The list changes; check that the videos are about MCP.",
},
{
description: "Convert degrees to radians",
prompt: "Use Arcade to convert 90 degrees to radians.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Math.DegToRad)",
expected_behavior: "Answers about 1.5708 radians (1.5707963267948966, which is pi/2).",
description: "Average a list of amounts",
prompt: "Use Arcade to average my monthly software bills: $1,249.50, $389.99, $72.25 and $418.26.",
tools_triggered: "Arcade_SelectTools, Arcade_UseTool (Math.Avg)",
expected_behavior: "Answers $532.50.",
},
],
negative: [
{
description: "General knowledge question that needs no tool",
prompt: "Use Arcade to explain the difference between TCP and UDP.",
prompt: "What's the difference between TCP and UDP?",
},
{
description: "Payment request; Arcade connects no payment app and does not move money",
prompt: "Use Arcade to send $50 to my landlord on Venmo.",
prompt: "Send $50 to my landlord on Venmo.",
},
{
description: "Concept question with nothing to calculate",
prompt: "Use Arcade to explain what a derivative is in calculus.",
description: "Concept question about a stock ratio, with no quote to fetch",
prompt: "Explain what a P/E ratio means when evaluating a stock.",
},
],
},
Expand All @@ -121,11 +133,9 @@ const applyOpenAiDirectory = (openai) => {
developerName,
category,
websiteURL,
logo,
composerIcon,
...rest
} = openai.interface;
for (const key of GENERATED_INTERFACE_KEYS) delete rest[key];
for (const key of [...GENERATED_INTERFACE_KEYS, ...OPENAI_IMAGE_KEYS]) delete rest[key];
openai.interface = {
displayName,
shortDescription,
Expand All @@ -138,8 +148,11 @@ const applyOpenAiDirectory = (openai) => {
privacyPolicyURL: privacyPolicyUrl,
termsOfServiceURL: openaiTermsUrl,
defaultPrompt: OPENAI_DEFAULT_PROMPTS,
logo,
composerIcon,
logo: OPENAI_LOGO,
logoDark: OPENAI_LOGO_DARK,
composerIcon: OPENAI_COMPOSER_ICON,
composerIconDark: OPENAI_COMPOSER_ICON_DARK,
brandColor: OPENAI_BRAND_COLOR,
...rest,
};
openai.review = OPENAI_REVIEW;
Expand Down Expand Up @@ -186,7 +199,7 @@ export const COPIED_FILES = {
* entry here — buildFiles throws if one is missing.
*/
export const FILE_SOURCES = {
// extensions.com.openai listing URLs, capabilities, defaultPrompt, review, and publication.
// extensions.com.openai listing URLs, images, brandColor, capabilities, defaultPrompt, review, and publication.
[PLUGIN_JSON]: ["dev/scripts/generate-manifests.mjs"],
// plugin.json and mcp.json supply identity and the gateway URL; VERSION
// supplies the version; hook-hosts.mjs supplies the hooks manifest path; the
Expand Down Expand Up @@ -347,18 +360,15 @@ const buildFiles = (root) => {
throw new Error(`plugin.json version ${plugin.version} does not match VERSION ${version}`);
}
const { url } = readJson(root, MCP_JSON).mcpServers.arcade;
const { interface: openaiListing } = plugin.extensions["com.openai"];
// The ChatGPT directory already lists a plugin named "Arcade" from another
// company, so the OpenAI listing uses "Arcade.dev". The other clients keep
// "Arcade".
const displayName = "Arcade";
if (!openaiListing.logo) {
throw new Error("plugin.json extensions.com.openai.interface.logo is missing");
}
// Codex wants the logo path with a leading "./"; Cursor takes it without one.
// The Claude marketplace has no logo field. `logo` is relative to the plugin
// directory; the repo-root Cursor manifest prefixes it with plugin/.
const logo = openaiListing.logo.replace(/^\.\//, "");
// Codex and ChatGPT want the logo path with a leading "./" (OPENAI_LOGO).
// Cursor takes it without one. The Claude marketplace has no logo field.
// `logo` is relative to the plugin directory; the repo-root Cursor manifest
// prefixes it with plugin/. Dark icons are not copied onto Cursor or Claude.
const logo = OPENAI_LOGO.replace(/^\.\//, "");
const { name, description, author, homepage, license, keywords, repository } = plugin;
const identity = { name, description, author, homepage, license, keywords, version };
const owner = { ...author, email: ACCEPTED_LISTING_OWNER_EMAIL };
Expand Down
34 changes: 32 additions & 2 deletions dev/scripts/package-chatgpt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ export const MAX_DEFAULT_PROMPT_LENGTH = 128;

const isMultiline = (value) => /[\r\n\u2028\u2029]/.test(value);

// sRGB relative luminance. OpenAI requires brandColor contrast of at least 2:1
// against white (https://developers.openai.com/plugins/deploy/submission).
const linearChannel = (byte) => {
const s = byte / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};

const contrastAgainstWhite = (hex) => {
const n = Number.parseInt(hex.slice(1), 16);
const luminance = 0.2126 * linearChannel((n >> 16) & 255)
+ 0.7152 * linearChannel((n >> 8) & 255)
+ 0.0722 * linearChannel(n & 255);
return 1.05 / (luminance + 0.05);
};

/** Returns every problem the portal would reject in the OpenAI listing. */
export const listingProblems = (listing) => {
const problems = [];
Expand All @@ -61,6 +76,18 @@ export const listingProblems = (listing) => {
problems.push(`category must be one of: ${LISTING_CATEGORIES.join(", ")}`);
}
if (!listing.logo) problems.push("logo is required");
if (listing.brandColor !== undefined && !/^#[0-9A-F]{6}$/i.test(listing.brandColor)) {
problems.push("brandColor must be a #RRGGBB color");
} else if (typeof listing.brandColor === "string" && contrastAgainstWhite(listing.brandColor) < 2) {
problems.push("brandColor must have at least 2:1 contrast against white");
}
for (const key of ["logo", "logoDark", "composerIcon", "composerIconDark"]) {
const value = listing[key];
if (value === undefined) continue;
if (typeof value !== "string" || !value.startsWith("./") || value.split("/").includes("..")) {
problems.push(`${key} must be a relative path starting with ./`);
}
}
// A remote MCP submission ("With MCP") needs all four listing URLs; only a
// skills-only upload can omit them. This ZIP always ships mcp.json.
for (const key of LISTING_URLS) {
Expand Down Expand Up @@ -114,8 +141,11 @@ const listFiles = (root, dir) =>
export const packageFiles = (root = ROOT) => {
const pluginRoot = pluginRootOf(root);
const plugin = JSON.parse(readFileSync(join(pluginRoot, "plugin.json"), "utf8"));
const { logo, composerIcon } = plugin.extensions["com.openai"].interface;
const images = [logo, composerIcon].filter(Boolean).map((path) => path.replace(/^\.\//, ""));
const listing = plugin.extensions["com.openai"].interface;
const images = ["logo", "logoDark", "composerIcon", "composerIconDark"]
.map((key) => listing[key])
.filter(Boolean)
.map((path) => path.replace(/^\.\//, ""));
return [...new Set(["plugin.json", "mcp.json", "LICENSE", ...listFiles(pluginRoot, "skills"), ...images])];
};

Expand Down
3 changes: 2 additions & 1 deletion dev/test/check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ test("plugin.json and mcp.json match the vendored Agent Plugins 1.0 schemas", ()
}
const openaiListing = plugin.extensions?.["com.openai"]?.interface;
assert.ok(openaiListing?.displayName, "plugin.json: Codex displayName");
for (const key of ["logo", "composerIcon"]) {
for (const key of ["logo", "logoDark", "composerIcon", "composerIconDark"]) {
const file = openaiListing?.[key];
assert.ok(file && existsSync(path.join(ROOT, "plugin", file)), `plugin.json: Codex ${key} must point at a committed file`);
// Codex's own plugins all write these paths with a leading "./".
assert.match(file, /^\.\//, `plugin.json: Codex ${key} must start with ./`);
}
assert.match(openaiListing.brandColor, /^#[0-9A-F]{6}$/i);
});

test("hand-written files name the gateway from mcp.json", () => {
Expand Down
2 changes: 2 additions & 0 deletions dev/test/directory-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ const COMPONENT_FOLDERS = ["agents/", "commands/", "skills/"];
// listing logo.
const ALLOWED_MEDIA_PATH_FIELDS = [
{ file: "plugin.json", field: ["extensions", "com.openai", "interface", "logo"] },
{ file: "plugin.json", field: ["extensions", "com.openai", "interface", "logoDark"] },
{ file: "plugin.json", field: ["extensions", "com.openai", "interface", "composerIcon"] },
{ file: "plugin.json", field: ["extensions", "com.openai", "interface", "composerIconDark"] },
{ file: ".cursor-plugin/plugin.json", field: ["logo"] },
];

Expand Down
18 changes: 18 additions & 0 deletions dev/test/manifests.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ test("Claude manifest displays Arcade without changing the install identifier",
assert.equal(manifest.name, "arcade");
});

test("OpenAI image variants and brandColor stay off the other client manifests", () => {
for (const file of [
"plugin/.cursor-plugin/plugin.json",
".cursor-plugin/plugin.json",
"plugin/.claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
".cursor-plugin/marketplace.json",
]) {
const text = readRepoFile(file);
for (const field of ["logoDark", "composerIcon", "composerIconDark", "brandColor", "logo-dark.png", "composer-icon"]) {
assert.equal(text.includes(field), false, `${file} contains ${field}`);
}
}
const cursor = JSON.parse(readRepoFile("plugin/.cursor-plugin/plugin.json"));
assert.equal(cursor.logo, "assets/logo.png");
assert.deepEqual(Object.keys(cursor).includes("brandColor"), false);
});

test("listing contact and privacy metadata agree across clients", () => {
const portable = JSON.parse(readRepoFile("plugin/plugin.json"));
const claude = JSON.parse(readRepoFile("plugin/.claude-plugin/plugin.json"));
Expand Down
Loading
Loading