diff --git a/README.md b/README.md index 1279eef..fb831d1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dev/scripts/generate-manifests.mjs b/dev/scripts/generate-manifests.mjs index 87201af..13daf71 100644 --- a/dev/scripts/generate-manifests.mjs +++ b/dev/scripts/generate-manifests.mjs @@ -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", @@ -43,11 +53,13 @@ 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. @@ -55,48 +67,48 @@ 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.", }, ], }, @@ -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, @@ -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; @@ -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 @@ -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 }; diff --git a/dev/scripts/package-chatgpt.mjs b/dev/scripts/package-chatgpt.mjs index d8fb7e6..61da9ab 100644 --- a/dev/scripts/package-chatgpt.mjs +++ b/dev/scripts/package-chatgpt.mjs @@ -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 = []; @@ -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) { @@ -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])]; }; diff --git a/dev/test/check.test.mjs b/dev/test/check.test.mjs index 22439fe..fb86d31 100644 --- a/dev/test/check.test.mjs +++ b/dev/test/check.test.mjs @@ -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", () => { diff --git a/dev/test/directory-policy.test.mjs b/dev/test/directory-policy.test.mjs index 59def10..af35a95 100644 --- a/dev/test/directory-policy.test.mjs +++ b/dev/test/directory-policy.test.mjs @@ -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"] }, ]; diff --git a/dev/test/manifests.test.mjs b/dev/test/manifests.test.mjs index d72269b..1b036de 100644 --- a/dev/test/manifests.test.mjs +++ b/dev/test/manifests.test.mjs @@ -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")); diff --git a/dev/test/package-chatgpt.test.mjs b/dev/test/package-chatgpt.test.mjs index 7d7bd1d..6872076 100644 --- a/dev/test/package-chatgpt.test.mjs +++ b/dev/test/package-chatgpt.test.mjs @@ -8,11 +8,14 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; +import { inflateSync } from "node:zlib"; import { archiveProblems, listingProblems, packageChatGPT } from "../scripts/package-chatgpt.mjs"; import { readRepoFile, ROOT } from "./helpers.mjs"; const plugin = JSON.parse(readRepoFile("plugin/plugin.json")); -const logo = plugin.extensions["com.openai"].interface.logo.replace(/^\.\//, ""); +const listingImages = ["logo", "logoDark", "composerIcon", "composerIconDark"].map((key) => + plugin.extensions["com.openai"].interface[key].replace(/^\.\//, ""), +); test("the OpenAI listing fits the portal's limits", () => { assert.deepEqual(listingProblems(plugin.extensions["com.openai"].interface), []); @@ -30,6 +33,13 @@ test("listingProblems reports what final directory submission rejects", () => { assert.deepEqual(listingProblems({ ...listing, defaultPrompt: ["Hi there", "Hi there"] }), ["defaultPrompt entries must be unique"]); assert.deepEqual(listingProblems({ ...listing, supportURL: undefined }), ["supportURL is required for a remote MCP submission"]); assert.deepEqual(listingProblems({ ...listing, termsOfServiceURL: undefined }), ["termsOfServiceURL is required for a remote MCP submission"]); + assert.deepEqual(listingProblems({ ...listing, brandColor: "#FFFFFF" }), [ + "brandColor must have at least 2:1 contrast against white", + ]); + assert.deepEqual(listingProblems({ ...listing, brandColor: "purple" }), ["brandColor must be a #RRGGBB color"]); + assert.deepEqual(listingProblems({ ...listing, logoDark: "assets/logo-dark.png" }), [ + "logoDark must be a relative path starting with ./", + ]); }); test("archiveProblems rejects hooks and apps on the manifest OpenAI reads", () => { @@ -60,15 +70,40 @@ test("the directory listing has the four MCP URLs and five positive review cases assert.equal("demo_recording_url" in openai.review, false); assert.deepEqual(openai.publication.countries, ["US"]); assert.doesNotMatch(openai.publication.release_notes, /\d+\.\d+\.\d+/); + assert.equal(openai.interface.logo, "./assets/logo.png"); + assert.equal(openai.interface.logoDark, "./assets/logo-dark.png"); + assert.equal(openai.interface.composerIcon, "./assets/composer-icon.png"); + assert.equal(openai.interface.composerIconDark, "./assets/composer-icon-dark.png"); + assert.equal(openai.interface.brandColor, "#4708F9"); assert.deepEqual(openai.review.test_cases.positive.map((item) => item.tools_triggered), [ - "Arcade_ListApps", + "Arcade_SelectTools", + "Arcade_SelectTools, Arcade_UseTool (GoogleFlights.SearchFlights), Arcade_UseTool (Math.Multiply)", + "Arcade_SelectTools, Arcade_UseTool (GoogleFinance.GetStockSummary)", + "Arcade_SelectTools, Arcade_UseTool (Youtube.SearchForVideos)", "Arcade_SelectTools, Arcade_UseTool (Math.Avg)", - "Arcade_SelectTools, Arcade_UseTool (Math.Power)", - "Arcade_SelectTools, Arcade_UseTool (Math.Sqrt)", - "Arcade_SelectTools, Arcade_UseTool (Math.DegToRad)", ]); - for (const item of [...openai.review.test_cases.positive, ...openai.review.test_cases.negative]) { - assert.match(item.prompt, /^Use Arcade to /, item.description); + assert.deepEqual(openai.review.test_cases.positive.map((item) => item.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.", + "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.", + "Use Arcade to get the Google Finance stock summary for NVDA.", + "Use Arcade to search YouTube for videos explaining the Model Context Protocol.", + "Use Arcade to average my monthly software bills: $1,249.50, $389.99, $72.25 and $418.26.", + ]); + assert.deepEqual(openai.review.test_cases.negative.map((item) => item.prompt), [ + "What's the difference between TCP and UDP?", + "Send $50 to my landlord on Venmo.", + "Explain what a P/E ratio means when evaluating a stock.", + ]); + for (const item of openai.review.test_cases.positive) { + assert.deepEqual(Object.keys(item), ["description", "prompt", "tools_triggered", "expected_behavior"]); + assert.ok(item.description.length <= 4000, item.description); + assert.ok(item.expected_behavior.length > 0 && item.expected_behavior.length <= 4000); + assert.equal(item.prompt.includes("\n"), false); + } + for (const item of openai.review.test_cases.negative) { + assert.deepEqual(Object.keys(item), ["description", "prompt"]); + assert.equal(item.prompt.includes("\n"), false); + assert.doesNotMatch(item.prompt, /^Use Arcade to /); } }); @@ -86,7 +121,7 @@ test("the ZIP has plugin.json at its root and only the portable plugin", { skip: const listed = spawnSync("unzip", ["-Z1", zipPath], { encoding: "utf8" }).stdout.trim().split("\n"); assert.deepEqual(listed, files); - for (const required of ["plugin.json", "mcp.json", "skills/try-arcade/SKILL.md", "skills/scale-arcade/SKILL.md", logo]) { + for (const required of ["plugin.json", "mcp.json", "skills/try-arcade/SKILL.md", "skills/scale-arcade/SKILL.md", ...listingImages]) { assert.ok(listed.includes(required), `missing ${required}`); } for (const name of listed) { @@ -102,8 +137,89 @@ test("the ZIP has plugin.json at its root and only the portable plugin", { skip: const validate = ajv.compile(JSON.parse(readRepoFile(`schemas/agent-plugins/1.0.0/${file.replace(".json", ".schema.json")}`))); assert.ok(validate(JSON.parse(readFileSync(path.join(out, file), "utf8"))), `${file}: ${ajv.errorsText(validate.errors)}`); } - assert.deepEqual(readFileSync(path.join(out, logo)), readFileSync(path.join(ROOT, "plugin", logo))); + for (const image of listingImages) { + const bytes = readFileSync(path.join(out, image)); + assert.deepEqual(bytes, readFileSync(path.join(ROOT, "plugin", image))); + assertOpaqueSquarePng(bytes, image, image.startsWith("assets/composer-icon") ? 256 : 512); + } } finally { rmSync(work, { recursive: true, force: true }); } }); + +const paeth = (left, up, upLeft) => { + const estimate = left + up - upLeft; + const leftDistance = Math.abs(estimate - left); + const upDistance = Math.abs(estimate - up); + const upLeftDistance = Math.abs(estimate - upLeft); + if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left; + if (upDistance <= upLeftDistance) return up; + return upLeft; +}; + +/** Square, RGBA or RGB, no tRNS, and no pixel with alpha below 255. */ +const assertOpaqueSquarePng = (buf, label, pixels) => { + assert.equal(buf.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", label); + assert.ok(buf.length <= 5 * 1024 * 1024, `${label} is over 5 MiB`); + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idat = []; + let transparentChunk = false; + while (offset + 12 <= buf.length) { + const length = buf.readUInt32BE(offset); + const type = buf.subarray(offset + 4, offset + 8).toString("ascii"); + const data = buf.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } else if (type === "IDAT") { + idat.push(data); + } else if (type === "tRNS") { + transparentChunk = true; + } else if (type === "IEND") { + break; + } + offset += 12 + length; + } + assert.equal(width, pixels, `${label} width`); + assert.equal(height, pixels, `${label} height`); + assert.equal(bitDepth, 8, `${label} bit depth`); + assert.equal(transparentChunk, false, `${label} has a tRNS chunk`); + // 2 = RGB, 6 = RGBA. Palette PNGs (color type 3) are what left a transparent + // strip in the previous logo. + assert.ok(colorType === 2 || colorType === 6, `${label} color type ${colorType}`); + if (colorType === 2) return; + const channels = 4; + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * channels; + const rows = []; + let pos = 0; + for (let y = 0; y < height; y++) { + const filter = raw[pos]; + pos += 1; + const row = Buffer.alloc(stride); + const previous = y > 0 ? rows[y - 1] : null; + for (let i = 0; i < stride; i++) { + const left = i >= channels ? row[i - channels] : 0; + const up = previous ? previous[i] : 0; + const upLeft = previous && i >= channels ? previous[i - channels] : 0; + const sample = raw[pos + i]; + if (filter === 0) row[i] = sample; + else if (filter === 1) row[i] = (sample + left) & 255; + else if (filter === 2) row[i] = (sample + up) & 255; + else if (filter === 3) row[i] = (sample + Math.floor((left + up) / 2)) & 255; + else if (filter === 4) row[i] = (sample + paeth(left, up, upLeft)) & 255; + else assert.fail(`${label} uses PNG filter ${filter}`); + } + pos += stride; + rows.push(row); + for (let x = 3; x < stride; x += 4) { + assert.equal(row[x], 255, `${label} has a transparent pixel on row ${y}`); + } + } +}; diff --git a/plugin/assets/composer-icon-dark.png b/plugin/assets/composer-icon-dark.png new file mode 100644 index 0000000..748e3f0 Binary files /dev/null and b/plugin/assets/composer-icon-dark.png differ diff --git a/plugin/assets/composer-icon.png b/plugin/assets/composer-icon.png new file mode 100644 index 0000000..748e3f0 Binary files /dev/null and b/plugin/assets/composer-icon.png differ diff --git a/plugin/assets/logo-dark.png b/plugin/assets/logo-dark.png new file mode 100644 index 0000000..6e9793e Binary files /dev/null and b/plugin/assets/logo-dark.png differ diff --git a/plugin/assets/logo.png b/plugin/assets/logo.png index d006146..6e9793e 100644 Binary files a/plugin/assets/logo.png and b/plugin/assets/logo.png differ diff --git a/plugin/plugin.json b/plugin/plugin.json index 6e7bcfb..59b020c 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -41,54 +41,57 @@ "Which GitHub issues are assigned to me?" ], "logo": "./assets/logo.png", - "composerIcon": "./assets/logo.png" + "logoDark": "./assets/logo-dark.png", + "composerIcon": "./assets/composer-icon.png", + "composerIconDark": "./assets/composer-icon-dark.png", + "brandColor": "#4708F9" }, "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." } ] },