Skip to content
6 changes: 3 additions & 3 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jam",
"version": "2.0.0",
"version": "1.36.3",
"description": "Jam recordings (video, console, network, logs) as context for your coding agent. Fix the bug in a Jam, prove a fix with a recorded Jam, mark up a screenshot, or turn a Jam into an end-to-end test.",
"author": {
"name": "Jam",
Expand All @@ -17,11 +17,11 @@
"session-replay",
"observability"
],
"mcpServers": "./mcp.json",
"mcpServers": "./.mcp.json",
"skills": "./skills/",
"interface": {
"displayName": "Jam",
"shortDescription": "Fix, prove and test from Jam recordings",
"shortDescription": "Debug and test from Jam links",
"longDescription": "Jam recordings (video, console, network, logs) as context for your coding agent. Fix the bug in a Jam, prove a fix with a recorded Jam, mark up a screenshot, or turn a Jam into an end-to-end test.",
"developerName": "Jam",
"category": "Developer Tools",
Expand Down
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"Jam": {
"type": "http",
"url": "https://mcp.jam.dev/mcp"
}
}
}
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ Guidance for coding agents working in this repository. Humans: see [CONTRIBUTING
## Layout

- `skills/<name>/SKILL.md`: the skills. Each folder also has `agents/openai.yaml`.
- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`: plugin manifests. `mcp.json` is the shared MCP server entry.
- `server.json`: the MCP Registry entry `dev.jam/jam`. Its `version` is the Jam MCP server's, not the plugin's. Pushing an `mcp-v*` tag publishes it, and a published version is permanent. Never push a tag unless asked.
- `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`: plugin manifests. `mcp.json` is the shared MCP server entry; `.mcp.json` is the Codex/OpenAI copy of it.
- `server.json`: the MCP Registry entry `dev.jam/jam`. Its `version` is the Jam MCP server's. `.codex-plugin/plugin.json` tracks that version; the other plugin manifests do not. Pushing an `mcp-v*` tag publishes it, and a published version is permanent. Never push a tag unless asked.
- `rules/`: a Cursor rule that guards the Jam MCP delete tools.
- `scripts/package-chatgpt.mjs`: the ChatGPT directory package. A skill that reads a Jam over MCP alone goes into its `SKILLS` list.
- `scripts/package-chatgpt.mjs`: the ChatGPT directory package. A skill that reads a Jam over MCP alone goes into its `SKILLS` list (`jam`, `jam-fix`, `jam-to-e2e`, `jam-to-tasks`, `jam-to-ticket`).
- `docs/plugin.md`: plugin sign-in, what the MCP server can do, permissions. The tool list itself lives at https://jam.dev/docs/jam-mcp; do not copy it here.
- `scripts/check.mjs`: repo checks, run by CI.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- ChatGPT/Codex directory package is submittable without hand-edits: `.mcp.json` (Jam URL plus `"type": "http"` for Claude plugin validate), a short description of at most 30 characters ("Debug and test from Jam links"), version 1.36.3 matching Jam MCP / `server.json`, and generated release notes. The packager stamps the version from `server.json`; CI fails if Codex drifts. The zip now ships `jam`, `jam-fix`, `jam-to-e2e`, `jam-to-tasks` and `jam-to-ticket`. `jam-to-tasks` and `jam-to-ticket` use Jam MCP first, with CLI steps in `references/cli.md`, so ChatGPT does not stop for a missing `jam` binary.
- Links to the Jam CLI and Jam MCP docs use their canonical URLs, `https://jam.dev/docs/cli` and `https://jam.dev/docs/jam-mcp`, instead of short links that redirect.
- Added `jam-setup`: set up Jam for a coding agent in one run. It installs the Jam CLI after the user says yes to the install command (the user runs it when they say no or the harness blocks it), signs in, connects Jam MCP with `add-mcp`, installs the skills with `skills`, and checks the result with `jam doctor`. It asks once whether to install for this project or every project, skips what is already set up, and skips MCP and skills when the Jam plugin is installed. `jam skills install` is the fallback without Node.js.
- `jam-proof`, `jam-fix`, `jam-to-e2e`, `jam-annotate` and `jam-proof-table` link the Jams they make: attach the pull request, ticket or CI run as a resource, and mark a proof Jam as `fixes` or `proves` of the bug Jam, so the bug Jam shows the backlink. `fixes` is used only for a fix the recording shows working.
Expand Down
15 changes: 9 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,27 @@ Then run the skill on a real task in at least one agent and say which one in the

Two things ship from this repo, each with its own version.

**The plugin.** Its version is the same in `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json` and `gemini-extension.json`; CI checks this.
**The plugin (Claude, Cursor, Gemini).** Its version is the same in `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json` and `gemini-extension.json`; CI checks this.

1. Bump the version in those four files and move **Unreleased** in the changelog under the new version.
1. Bump the version in those three files and move **Unreleased** in the changelog under the new version.
2. Merge to `master` and push a `v<version>` tag.

**The MCP Registry entry.** `server.json` describes the remote Jam MCP server at `mcp.jam.dev`, so its `version` is the server's, not the plugin's. When the server releases:
**The MCP Registry entry, and the ChatGPT/Codex listing.** `server.json` describes the remote Jam MCP server at `mcp.jam.dev`. Its `version` is the server's. The Codex plugin version in `.codex-plugin/plugin.json` must match it so an OpenAI directory update can ship with the server (OpenAI requires the new version to be greater than the one already published). CI checks the match. When the server releases:

1. Set `version` in `server.json` to the server's version and merge to `master`.
1. Set `version` in `server.json` and `.codex-plugin/plugin.json` to the server's version and merge to `master`.
2. Push an `mcp-v<version>` tag. `.github/workflows/publish-mcp.yml` publishes `server.json` to the registry as `dev.jam/jam`. A registry version can't be changed after it is published.
3. Rebuild the ChatGPT package (below). `scripts/package-chatgpt.mjs` stamps the zip from `server.json`, so the packaged version cannot drift.

### ChatGPT directory package

The ChatGPT directory takes the skills that read a Jam over MCP alone: `jam-fix` and `jam-to-e2e`, without their `references/`. The CLI steps (in `jam-proof`, `jam-annotate` and the `references/cli.md` files) run local commands, which ChatGPT's external-code review can flag, so a skill meant for ChatGPT keeps them in `references/` and is added to `SKILLS` in `scripts/package-chatgpt.mjs`. Build the package from `master` after the merge: the packaged skills link to `references/` on GitHub.
The ChatGPT directory takes the skills that read a Jam over MCP alone: `jam`, `jam-fix`, `jam-to-e2e`, `jam-to-tasks` and `jam-to-ticket`, without their `references/`. Keep CLI and shell skills (`jam-proof`, `jam-annotate`, `jam-setup`, `jam-proof-table`, `video-to-jam`, `record-demo`, `record-terminal-proof`, `add-voiceover`) out of the zip. The CLI steps (in those skills and in `references/cli.md`) run local commands, which ChatGPT's external-code review can flag, so a skill meant for ChatGPT keeps them in `references/` and is added to `SKILLS` in `scripts/package-chatgpt.mjs`. Build the package from `master` after the merge: the packaged skills link to `references/` on GitHub.

OpenAI submission also requires the Codex MCP config as `.mcp.json` (`mcpServers` is `"./.mcp.json"`) and `interface.shortDescription` of at most 30 characters. `.mcp.json` is Cursor's `mcp.json` plus `"type": "http"`, which Claude Code's plugin validate requires. The packager writes both, and `scripts/check.mjs` fails if they drift.

```bash
node scripts/package-chatgpt.mjs ../jam-chatgpt-<version>.zip
```

The script copies those two skills with the plugin identity, connection and assets, and rewrites the listing description so it names only what the package contains.
The script copies those five skills with the plugin identity, connection and assets, stamps the version from `server.json`, and rewrites the listing description so it names only what the package contains.

Update the existing app entry with this package. Do not create a new app through **Upload New**; that loses the saved identity and connection settings.
48 changes: 46 additions & 2 deletions scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { parseArgs, stripVTControlCharacters } from "node:util";
import { isMap, parseDocument } from "yaml";
import { SHORT_DESCRIPTION_MAX, codexMcpConfig } from "./package-chatgpt.mjs";

const errors = [];
const fail = (msg) => errors.push(msg);
Expand Down Expand Up @@ -81,10 +82,10 @@ if (new Set(setupBlocks.values()).size > 1) {
fail(`CLI install blocks differ across ${[...setupBlocks.keys()].join(", ")}; make them identical`);
}

// Versions. server.json is not here: it carries the Jam MCP server's version, not the plugin's.
// Claude, Cursor and Gemini share a plugin version. ChatGPT/Codex tracks the
// Jam MCP server in server.json so an OpenAI directory update can ship with it.
const manifests = [
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
".cursor-plugin/plugin.json",
"gemini-extension.json",
];
Expand All @@ -93,6 +94,49 @@ if (new Set(versions.map(([, v]) => v)).size !== 1) {
fail(`versions differ: ${versions.map(([p, v]) => `${p}=${v}`).join(", ")}`);
}

const mcpVersion = JSON.parse(read("server.json")).version;
const codex = JSON.parse(read(".codex-plugin/plugin.json"));
if (codex.version !== mcpVersion) {
fail(`.codex-plugin/plugin.json version ${codex.version} must match server.json ${mcpVersion}`);
}
if (codex.mcpServers !== "./.mcp.json") {
fail(`.codex-plugin/plugin.json mcpServers must be "./.mcp.json" for Codex/OpenAI submission`);
}
const shortDescription = codex.interface?.shortDescription;
if (typeof shortDescription !== "string" || !shortDescription.trim() || /[\r\n]/.test(shortDescription)) {
fail(`.codex-plugin/plugin.json interface.shortDescription must be one non-empty line`);
} else if (shortDescription.length > SHORT_DESCRIPTION_MAX) {
fail(`.codex-plugin/plugin.json interface.shortDescription is ${shortDescription.length} chars, max ${SHORT_DESCRIPTION_MAX}`);
}
let mcp;
if (!existsSync("mcp.json")) fail("missing mcp.json");
else {
try {
mcp = JSON.parse(read("mcp.json"));
} catch (error) {
if (error instanceof SyntaxError) fail("mcp.json is not valid JSON");
else throw error;
}
}
const isMcpObject = mcp !== null && typeof mcp === "object" && !Array.isArray(mcp);
const jamUrl = isMcpObject ? mcp.mcpServers?.Jam?.url : undefined;
if (mcp !== undefined && !isMcpObject) fail("mcp.json must be a JSON object");
else if (isMcpObject && (typeof jamUrl !== "string" || !jamUrl)) fail("mcp.json is missing mcpServers.Jam.url");
if (!existsSync(".mcp.json")) fail("missing .mcp.json (Codex/OpenAI MCP config)");
else {
try {
const actual = JSON.parse(read(".mcp.json"));
if (actual === null || typeof actual !== "object" || Array.isArray(actual)) {
fail(".mcp.json must be a JSON object");
} else if (typeof jamUrl === "string" && jamUrl && JSON.stringify(actual) !== JSON.stringify(codexMcpConfig(mcp))) {
fail(".mcp.json must be mcp.json's Jam URL with type \"http\" (Claude plugin validate)");
}
} catch (error) {
if (error instanceof SyntaxError) fail(".mcp.json is not valid JSON");
else throw error;
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}

// Relative links in Markdown
for (const file of mdFiles) {
const text = read(file).replace(/```[\s\S]*?```/g, "");
Expand Down
95 changes: 94 additions & 1 deletion scripts/check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ function fixture(t, frontmatter = "name: sample-skill\ndescription: A useful ski
write(skillPath, `---\n${frontmatter}\n---\n# Sample skill\n`);
write("skills/sample-skill/agents/openai.yaml", "interface: {}\n");
write("README.md", "- [sample-skill](./skills/sample-skill/SKILL.md): x\n");
for (const file of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json", "gemini-extension.json"]) {
write("server.json", '{"version":"1.36.3"}\n');
write("mcp.json", '{"mcpServers":{"Jam":{"url":"https://mcp.jam.dev/mcp"}}}\n');
write(".mcp.json", '{"mcpServers":{"Jam":{"type":"http","url":"https://mcp.jam.dev/mcp"}}}\n');
write(".codex-plugin/plugin.json", JSON.stringify({
version: "1.36.3",
mcpServers: "./.mcp.json",
interface: { shortDescription: "Debug and test from Jam links" },
}) + "\n");
for (const file of [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json", "gemini-extension.json"]) {
write(file, '{"version":"1.0.0"}\n');
}
execFileSync("git", ["init", "--quiet"], { cwd });
Expand Down Expand Up @@ -190,3 +198,88 @@ test("jam router must name every other skill, and only real ones", (t) => {
assert.match(result.output, /routing table does not name sample-skill/);
assert.match(result.output, /routing table names missing skill jam-fix/);
});

test("fails when Codex version does not track server.json", (t) => {
const repo = fixture(t);
repo.write(".codex-plugin/plugin.json", JSON.stringify({
version: "2.0.0",
mcpServers: "./.mcp.json",
interface: { shortDescription: "Debug and test from Jam links" },
}) + "\n");
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /must match server\.json 1\.36\.3/);
});

test("fails when Codex short description exceeds the OpenAI limit", (t) => {
const repo = fixture(t);
repo.write(".codex-plugin/plugin.json", JSON.stringify({
version: "1.36.3",
mcpServers: "./.mcp.json",
interface: { shortDescription: "Fix bugs and write tests from Jam recordings" },
}) + "\n");
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /shortDescription is 44 chars, max 30/);
});

test("fails when Codex MCP path is not .mcp.json", (t) => {
const repo = fixture(t);
repo.write(".codex-plugin/plugin.json", JSON.stringify({
version: "1.36.3",
mcpServers: "./mcp.json",
interface: { shortDescription: "Debug and test from Jam links" },
}) + "\n");
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /mcpServers must be "\.\/\.mcp\.json"/);
});

test("fails when .mcp.json drifts from mcp.json", (t) => {
const repo = fixture(t);
repo.write(".mcp.json", '{"mcpServers":{"Jam":{"type":"http","url":"https://example.invalid/mcp"}}}\n');
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /\.mcp\.json must be mcp\.json's Jam URL with type "http"/);
});

test("fails when .mcp.json is missing type http", (t) => {
const repo = fixture(t);
repo.write(".mcp.json", '{"mcpServers":{"Jam":{"url":"https://mcp.jam.dev/mcp"}}}\n');
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /\.mcp\.json must be mcp\.json's Jam URL with type "http"/);
});

test("reports missing MCP files without aborting other checks", (t) => {
const repo = fixture(t);
rmSync(join(repo.cwd, "mcp.json"));
rmSync(join(repo.cwd, ".mcp.json"));
repo.write("skills/sample-skill/references/new.md", "[missing](./missing.md)\n");
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /missing mcp\.json/);
assert.match(result.output, /missing \.mcp\.json/);
assert.match(result.output, /broken link \.\/missing.md/);
assert.doesNotMatch(result.output, /ENOENT/);
});

test("reports a missing Jam URL without aborting other checks", (t) => {
const repo = fixture(t);
repo.write("mcp.json", '{"mcpServers":{}}\n');
repo.write("skills/sample-skill/references/new.md", "[missing](./missing.md)\n");
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /mcp\.json is missing mcpServers\.Jam\.url/);
assert.match(result.output, /broken link \.\/missing.md/);
});

for (const [label, body] of [["null", "null\n"], ["false", "false\n"]]) {
test(`fails when mcp.json is ${label}`, (t) => {
const repo = fixture(t);
repo.write("mcp.json", body);
const result = repo.run();
assert.equal(result.status, 1, result.output);
assert.match(result.output, /mcp\.json must be a JSON object/);
});
}
Loading
Loading