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
13 changes: 7 additions & 6 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
*.log
.env
.claude/
dist/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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-<version>.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`.
Expand Down
Binary file added assets/checkly-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion assets/checkly-logo.svg

This file was deleted.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions scripts/package.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string[]> {
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);
});
}
49 changes: 49 additions & 0 deletions test/codex-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
});
79 changes: 79 additions & 0 deletions test/package.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
Loading