diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 90dc86a..c967da8 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -23,21 +23,22 @@ "apps": "./.app.json", "interface": { "displayName": "Checkly", - "shortDescription": "Create and manage Checkly monitoring from Codex.", - "longDescription": "Use Checkly's agent skill to initialize monitoring-as-code projects, create API and browser checks, investigate failures, and manage account features with the Checkly CLI.", + "shortDescription": "Synthetic monitoring as code", + "longDescription": "Checkly is synthetic monitoring as code: API checks, browser checks, URL and uptime monitors, and Playwright Check Suites that run from global locations and alert you when something breaks.\n\nThis plugin bundles two skills and the Checkly MCP server. The skills teach Codex how to scaffold monitoring-as-code projects with the Checkly CLI, write API and browser checks, and apply Playwright best practices for stable end-to-end tests. The MCP server connects to your Checkly account so Codex can list and inspect checks, read check results and test sessions, trigger checks, run root cause analyses, and manage API checks, monitors, check groups, and status page incidents.\n\nIntended for developers and SREs who already use Checkly or want to add monitoring to a project without leaving their editor. A Checkly account is required; the MCP server authenticates with OAuth.\n\nLimitations: Playwright Check Suites and checks whose code lives in a repository are managed with the Checkly CLI, not through MCP tools. Environment variables can be read but not created or updated from this plugin.", "developerName": "Checkly", "category": "Developer Tools", "capabilities": ["Instructions", "Monitoring", "CLI workflows"], "websiteURL": "https://www.checklyhq.com", - "privacyPolicyURL": "https://www.checklyhq.com/privacy", - "termsOfServiceURL": "https://www.checklyhq.com/terms", + "supportURL": "https://www.checklyhq.com/support/", + "privacyPolicyURL": "https://www.checklyhq.com/privacy/", + "termsOfServiceURL": "https://www.checklyhq.com/terms/", "defaultPrompt": [ "Create a Checkly browser check for this flow.", "Add API monitoring for this endpoint.", "Investigate failing Checkly checks." ], "brandColor": "#0075FF", - "composerIcon": "./assets/checkly-logo.svg", - "logo": "./assets/checkly-logo.svg" + "composerIcon": "./assets/checkly-logo.png", + "logo": "./assets/checkly-logo.png" } } diff --git a/.gitignore b/.gitignore index 22dc3af..aefb8c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ *.log .env .claude/ +dist/ diff --git a/README.md b/README.md index 5441bf0..5e56e61 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Checkly skills for AI coding agents packaged as a single plugin that targets Cod skills/ Skills (one directory per skill) .mcp.json Checkly MCP server configuration scripts/sync.ts Pulls externally-authored skills from their source repos +scripts/package.ts Builds the ZIP for OpenAI plugin submission skills.config.ts Declares where each externally-authored skill comes from ``` @@ -54,6 +55,14 @@ Point Cursor at this repo to install the plugin. | [checkly](skills/checkly/) | [checkly/checkly-cli](https://github.com/checkly/checkly-cli/tree/main/skills/checkly) — tracks `main`, synced daily | | [playwright-best-practices-for-agents](skills/playwright-best-practices-for-agents/) | [checkly/docs](https://github.com/checkly/docs/tree/main/skills/playwright-best-practices-for-agents) — tracks `main`, synced daily | +## Packaging for OpenAI submission + +``` +npm run package +``` + +Writes `dist/checkly-plugin-.zip` containing only the files the Codex plugin needs: the Codex manifest, `.mcp.json`, `.app.json`, `skills/`, `assets/`, `LICENSE`, and `README.md`. The file list comes from `git ls-files`, so untracked files such as `.env` never end up in the archive. Upload the ZIP at the [OpenAI plugin dashboard](https://developers.openai.com/plugins/deploy/submission). + ## Adding more - **Externally-authored** (lives in another repo, synced here): add an entry to [`skills.config.ts`](skills.config.ts) and run `npm run sync`. diff --git a/assets/checkly-logo.png b/assets/checkly-logo.png new file mode 100644 index 0000000..23a1e97 Binary files /dev/null and b/assets/checkly-logo.png differ diff --git a/assets/checkly-logo.svg b/assets/checkly-logo.svg deleted file mode 100644 index 27773cf..0000000 --- a/assets/checkly-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/package.json b/package.json index b796479..12775e5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "type": "module", "scripts": { "sync": "node scripts/sync.ts", + "package": "node scripts/package.ts", "test": "node --test test/*.test.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", diff --git a/scripts/package.ts b/scripts/package.ts new file mode 100644 index 0000000..ee47212 --- /dev/null +++ b/scripts/package.ts @@ -0,0 +1,83 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs, promisify } from "node:util"; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", ".."); +const run = promisify(execFile); + +/** + * Files that go into the Codex plugin ZIP. Everything else in the repo is + * tooling (sync script, tests, CI, other agents' manifests) and must not ship. + * + * OpenAI requires the ZIP to contain a single plugin root with no secrets, so + * the list is built from `git ls-files` and filtered here: untracked files + * such as `.env` or `node_modules/` can never slip in. + */ +const PACKAGE_FILES = new Set([ + ".codex-plugin/plugin.json", + ".mcp.json", + ".app.json", + "LICENSE", + "README.md", +]); +const PACKAGE_DIRS = ["assets/", "skills/"]; + +export function selectPackageFiles(trackedFiles: string[]): string[] { + return trackedFiles.filter( + (file) => PACKAGE_FILES.has(file) || PACKAGE_DIRS.some((dir) => file.startsWith(dir)), + ); +} + +async function listTrackedFiles(root: string): Promise { + const { stdout } = await run("git", ["-C", root, "ls-files", "-z"]); + return stdout.split("\0").filter(Boolean); +} + +/** + * Build the plugin ZIP at `outFile` from the tracked files under `root`. + * Returns the list of files that were packaged. + */ +export async function createPackage({ + root, + outFile, +}: { + root: string; + outFile: string; +}): Promise { + const files = selectPackageFiles(await listTrackedFiles(root)); + await mkdir(dirname(outFile), { recursive: true }); + await rm(outFile, { force: true }); + // -X drops extra file attributes so the archive is reproducible across machines. + await run("zip", ["-X", "-q", outFile, ...files], { cwd: root }); + return files; +} + +async function main() { + const { values } = parseArgs({ + options: { + out: { type: "string" }, + }, + }); + const manifest = JSON.parse( + await readFile(join(REPO_ROOT, ".codex-plugin", "plugin.json"), "utf8"), + ); + const outFile = resolve( + REPO_ROOT, + values.out ?? join("dist", `${manifest.name}-plugin-${manifest.version}.zip`), + ); + const files = await createPackage({ root: REPO_ROOT, outFile }); + for (const file of files) { + console.log(`packaged ${file}`); + } + console.log(`\n${files.length} file(s) written to ${outFile}`); +} + +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; +if (isMain) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/test/codex-manifest.test.ts b/test/codex-manifest.test.ts new file mode 100644 index 0000000..c477c75 --- /dev/null +++ b/test/codex-manifest.test.ts @@ -0,0 +1,49 @@ +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test } from "node:test"; + +const ROOT = join(import.meta.dirname, ".."); + +async function readManifest() { + return JSON.parse(await readFile(join(ROOT, ".codex-plugin", "plugin.json"), "utf8")); +} + +function pngDimensions(buffer: Buffer) { + assert.equal(buffer.subarray(1, 4).toString("ascii"), "PNG", "expected a PNG file"); + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +// Limits from https://developers.openai.com/plugins/deploy/submission-errors +test("Codex listing text stays within OpenAI's final-submission limits", async () => { + const { interface: ui } = await readManifest(); + + assert.ok(ui.displayName.length <= 30, "displayName must be at most 30 characters"); + assert.ok(ui.shortDescription.length <= 30, "shortDescription must be at most 30 characters"); + assert.ok(ui.longDescription.length <= 4000, "longDescription must be at most 4000 characters"); + assert.ok(ui.developerName.length <= 80, "developerName must be at most 80 characters"); + assert.ok(ui.capabilities.length <= 20, "at most 20 capabilities"); + assert.ok(ui.defaultPrompt.length <= 3, "at most 3 starter prompts"); + for (const prompt of ui.defaultPrompt) { + assert.ok(prompt.length <= 128, `starter prompt too long: ${prompt}`); + } +}); + +test("Codex listing declares every URL OpenAI requires for a remote MCP plugin", async () => { + const { interface: ui } = await readManifest(); + + for (const key of ["websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"]) { + assert.match(ui[key] ?? "", /^https:\/\//, `${key} must be an https URL`); + } +}); + +test("Codex icon and logo point at an existing square PNG", async () => { + const { interface: ui } = await readManifest(); + + for (const key of ["composerIcon", "logo"]) { + assert.match(ui[key], /^\.\/assets\/.+\.png$/, `${key} must be a ./assets PNG`); + const { width, height } = pngDimensions(await readFile(join(ROOT, ui[key]))); + assert.equal(width, height, `${key} must be square`); + assert.ok(width >= 48, `${key} must be at least 48px`); + } +}); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..71c6f62 --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,79 @@ +import { strict as assert } from "node:assert"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "node:test"; +import { promisify } from "node:util"; + +import { createPackage, selectPackageFiles } from "../scripts/package.ts"; + +const run = promisify(execFile); +const ROOT = join(import.meta.dirname, ".."); + +describe("selectPackageFiles", () => { + test("keeps only what the Codex plugin needs at runtime", () => { + const tracked = [ + ".codex-plugin/plugin.json", + ".mcp.json", + ".app.json", + "LICENSE", + "README.md", + "assets/checkly-logo.png", + "skills/checkly/SKILL.md", + "skills/playwright-best-practices-for-agents/references/auth.md", + // dev-only files that must never ship + ".env", + ".gitignore", + ".github/workflows/verify.yml", + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", + ".plugin/plugin.json", + "package.json", + "package-lock.json", + "scripts/sync.ts", + "skills.config.ts", + "test/sync.test.ts", + "tsconfig.json", + ]; + + assert.deepEqual(selectPackageFiles(tracked), [ + ".codex-plugin/plugin.json", + ".mcp.json", + ".app.json", + "LICENSE", + "README.md", + "assets/checkly-logo.png", + "skills/checkly/SKILL.md", + "skills/playwright-best-practices-for-agents/references/auth.md", + ]); + }); +}); + +describe("createPackage", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "checkly-plugin-package-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("zips the tracked plugin files from the repo root", async () => { + const outFile = join(dir, "plugin.zip"); + const files = await createPackage({ root: ROOT, outFile }); + + const { stdout } = await run("unzip", ["-Z1", outFile]); + const entries = stdout.trim().split("\n").sort(); + + assert.deepEqual(entries, [...files].sort()); + assert.ok(entries.includes(".codex-plugin/plugin.json")); + assert.ok(entries.includes(".mcp.json")); + assert.ok(entries.includes("assets/checkly-logo.png")); + assert.ok(entries.includes("skills/checkly/SKILL.md")); + assert.ok( + !entries.some((e) => e === ".env" || e.startsWith("test/") || e.startsWith("node_modules/")), + ); + assert.ok(!entries.includes("package.json")); + }); +});