mcplane 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -18
  3. package/dist/agent-plugins.d.ts +6 -0
  4. package/dist/agent-plugins.js +76 -0
  5. package/dist/cdp.d.ts +49 -0
  6. package/dist/cdp.js +253 -0
  7. package/dist/checks/ard.d.ts +67 -0
  8. package/dist/checks/ard.js +215 -0
  9. package/dist/checks/auth.js +2 -1
  10. package/dist/checks/chain.d.ts +45 -0
  11. package/dist/checks/chain.js +171 -0
  12. package/dist/checks/listing.js +23 -14
  13. package/dist/checks/oauth.d.ts +45 -0
  14. package/dist/checks/oauth.js +64 -0
  15. package/dist/checks/readiness.d.ts +58 -0
  16. package/dist/checks/readiness.js +230 -0
  17. package/dist/checks/tools.js +38 -7
  18. package/dist/checks/vercel.d.ts +27 -0
  19. package/dist/checks/vercel.js +153 -0
  20. package/dist/cli.js +54 -6
  21. package/dist/core.d.ts +1 -0
  22. package/dist/core.js +20 -4
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +10 -3
  26. package/dist/evals.d.ts +34 -0
  27. package/dist/evals.js +75 -0
  28. package/dist/extras.js +1 -1
  29. package/dist/images.d.ts +21 -0
  30. package/dist/images.js +99 -0
  31. package/dist/listings.d.ts +14 -0
  32. package/dist/listings.js +40 -1
  33. package/dist/mcp-client.d.ts +1 -5
  34. package/dist/mcp-client.js +8 -1
  35. package/dist/mcp-server.js +21 -11
  36. package/dist/packs.d.ts +48 -18
  37. package/dist/packs.js +380 -90
  38. package/dist/publish.js +13 -4
  39. package/dist/report.js +30 -13
  40. package/dist/skills.d.ts +21 -0
  41. package/dist/skills.js +180 -0
  42. package/dist/submissions.js +2 -1
  43. package/dist/types.d.ts +37 -4
  44. package/dist/types.js +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/video.d.ts +58 -0
  48. package/dist/video.js +190 -0
  49. package/dist/zip.d.ts +16 -2
  50. package/dist/zip.js +65 -5
  51. package/package.json +4 -1
  52. package/schema.json +86 -12
  53. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  54. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  55. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
package/dist/demo.js ADDED
@@ -0,0 +1,573 @@
1
+ /**
2
+ * `mcplane demo`: fastlane's snapshot, for the agent era.
3
+ *
4
+ * ChatGPT's review asks for a demo video recorded in developer mode that shows
5
+ * the main use cases and tools. This records it from the review tests in
6
+ * mcplane.json: it drives the ChatGPT desktop app (macOS) over the Chrome
7
+ * DevTools Protocol, opens a new chat per test with your plugin @mentioned,
8
+ * types the prompt, waits for the answer and records the window. Then ffmpeg
9
+ * joins the scenes behind a title card, one caption per scene.
10
+ *
11
+ * It uses your ChatGPT account, so it asks first, hides the sidebar (your chats
12
+ * and name) before it records, only records while a test runs, and approves tool
13
+ * calls only with --approve ("Allow once", never "Always allow").
14
+ */
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { existsSync, writeFileSync } from 'node:fs';
17
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { createInterface } from 'node:readline/promises';
21
+ import { setTimeout as sleep } from 'node:timers/promises';
22
+ import { Cdp, listTargets, pickMainWindow } from './cdp.js';
23
+ import { ensureLocalDir } from './manifest.js';
24
+ import { listTools } from './packs.js';
25
+ import { buildVideo, findFfmpeg, jpegSize, outputSize } from './video.js';
26
+ const APP = '/Applications/ChatGPT.app';
27
+ const BINARY = `${APP}/Contents/MacOS/ChatGPT`;
28
+ const MAIN_WINDOW = 'app://-/index.html';
29
+ const PORT = 9333;
30
+ /** Keep frames coming when the window is covered or in the background. */
31
+ const KEEP_RENDERING = ['--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling'];
32
+ const REPLY_TIMEOUT = 5 * 60_000;
33
+ /**
34
+ * Every label mcplane relies on in ChatGPT desktop (checked against 26.903, 1 October 2026).
35
+ * When a release renames something, this is the one place to fix.
36
+ */
37
+ export const UI = {
38
+ hideSidebar: 'Hide sidebar',
39
+ showSidebar: 'Show sidebar',
40
+ newChat: 'New chat',
41
+ chatMode: 'Chat',
42
+ workMode: 'Work',
43
+ addMenu: 'Add files and more',
44
+ send: ['Send', 'Send prompt', 'Send message'],
45
+ stop: ['Stop', 'Stop generating', 'Stop streaming'],
46
+ /** Buttons on a tool approval card. */
47
+ approval: ['Allow once', 'Always allow', 'Deny', 'Allow'],
48
+ /** What --approve clicks, first match wins. Never "Always allow": a demo shouldn't change your settings. */
49
+ allow: ['Allow once', 'Allow'],
50
+ /** The collapsed summary of tool calls ("Worked for 5s"), opened at the end of each scene. */
51
+ activity: '^(Worked|Thought|Working) for ',
52
+ signIn: ['Log in', 'Sign up', 'Sign in'],
53
+ /** Part of the message box's accessible name: "Message ChatGPT", "Ask ChatGPT", "Work with ChatGPT". */
54
+ composer: 'ChatGPT',
55
+ /** The @mention pill keeps the plugin's id here; developer-mode plugins are "created-by-me". */
56
+ mentionAttr: 'plugin-mention-path',
57
+ devMode: 'created-by-me',
58
+ };
59
+ /** Helpers every page script starts with. Plain strings: these run inside ChatGPT, not Node. */
60
+ const PAGE = String.raw `
61
+ const vis = (el) => {
62
+ const r = el.getBoundingClientRect();
63
+ if (r.width < 2 || r.height < 2) return false;
64
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
65
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
66
+ const hit = document.elementFromPoint(x, y);
67
+ return !!hit && (hit === el || el.contains(hit));
68
+ };
69
+ const label = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
70
+ const buttons = () => [...document.querySelectorAll('button, [role=button]')].filter(vis);
71
+ const button = (names) => buttons().find((b) => names.includes(label(b))) || null;
72
+ const center = (el) => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; };
73
+ const composer = () => {
74
+ const eds = [...document.querySelectorAll('[contenteditable=true], textarea')].filter(vis);
75
+ return eds.find((e) => (e.getAttribute('aria-label') || e.getAttribute('placeholder') || '').includes(UI.composer)) || eds[eds.length - 1] || null;
76
+ };
77
+ `;
78
+ /** The ChatGPT desktop window, driven through DevTools. */
79
+ class Desktop {
80
+ cdp;
81
+ signal;
82
+ frameNo = 0;
83
+ pluginPick = null;
84
+ constructor(cdp, signal) {
85
+ this.cdp = cdp;
86
+ this.signal = signal;
87
+ }
88
+ page(body) {
89
+ return this.cdp.evaluate(`(() => { const UI = ${JSON.stringify(UI)};\n${PAGE}\n${body}\n})()`);
90
+ }
91
+ wait(ms) {
92
+ return sleep(ms, undefined, { signal: this.signal });
93
+ }
94
+ /** Polls until `fn` returns something truthy. */
95
+ async until(fn, ms, what) {
96
+ const end = Date.now() + ms;
97
+ for (;;) {
98
+ const v = await fn();
99
+ if (v)
100
+ return v;
101
+ if (Date.now() > end)
102
+ throw new Error(`Timed out waiting for ${what}.`);
103
+ await this.wait(250);
104
+ }
105
+ }
106
+ async mouse(type, p) {
107
+ await this.cdp.send('Input.dispatchMouseEvent', { type, x: p.x, y: p.y, ...(type === 'mouseMoved' ? {} : { button: 'left', clickCount: 1 }) });
108
+ }
109
+ /** A real click, then the pointer parks at the window's right edge so no hover state or tooltip stays on screen. */
110
+ async clickAt(p) {
111
+ await this.mouse('mouseMoved', p);
112
+ await this.mouse('mousePressed', p);
113
+ await this.mouse('mouseReleased', p);
114
+ const size = await this.page('return { w: innerWidth, h: innerHeight };');
115
+ await this.mouse('mouseMoved', { x: size.w - 3, y: Math.round(size.h / 2) });
116
+ }
117
+ /** Clicks the first visible button with one of these labels; false when there is none. */
118
+ async click(names) {
119
+ const p = await this.page(`const b = button(${JSON.stringify(names)}); return b && center(b);`);
120
+ if (!p)
121
+ return false;
122
+ await this.clickAt(p);
123
+ return true;
124
+ }
125
+ /** On macOS, shortcuts like Cmd+A only edit when the command is named; see `commands`. */
126
+ async key(key, code, keyCode, modifiers = 0, text, commands) {
127
+ const base = { key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers };
128
+ await this.cdp.send('Input.dispatchKeyEvent', { ...base, type: text ? 'keyDown' : 'rawKeyDown', ...(text ? { text, unmodifiedText: text } : {}), ...(commands ? { commands } : {}) });
129
+ await this.cdp.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
130
+ }
131
+ /** Waits for the app to load and settle; fails clearly when it's signed out. */
132
+ async ready() {
133
+ const state = await this.until(() => this.page(`if (button(UI.signIn)) return 'signed-out'; return button([UI.hideSidebar, UI.showSidebar]) || composer() ? 'ready' : null;`), 60_000, 'ChatGPT to load');
134
+ if (state === 'signed-out')
135
+ throw new Error('ChatGPT is signed out. Sign in, then run mcplane demo again.');
136
+ // Controls keep arriving for a second or two after the window loads (the Chat/Work
137
+ // switch, the plugin list), so wait until the set of buttons stops changing.
138
+ const end = Date.now() + 15_000;
139
+ let last = -1;
140
+ let since = Date.now();
141
+ while (Date.now() < end) {
142
+ const n = await this.page('return buttons().length;');
143
+ if (n !== last)
144
+ [last, since] = [n, Date.now()];
145
+ else if (Date.now() - since >= 1_500)
146
+ break;
147
+ await this.wait(250);
148
+ }
149
+ await this.key('Escape', 'Escape', 27); // close anything left open (menus, what's-new cards)
150
+ }
151
+ sidebarOpen() {
152
+ return this.page('return !!button([UI.hideSidebar]);');
153
+ }
154
+ async hideSidebar() {
155
+ if (!(await this.click([UI.hideSidebar])))
156
+ return;
157
+ await this.until(async () => !(await this.sidebarOpen()), 5_000, 'the sidebar to close');
158
+ }
159
+ async showSidebar() {
160
+ if (await this.click([UI.showSidebar]))
161
+ await this.wait(400);
162
+ }
163
+ /** An empty chat, sidebar closed. Uses a visible "New chat" control, or the sidebar's. */
164
+ async newChat() {
165
+ if (!(await this.click([UI.newChat]))) {
166
+ if (!(await this.click([UI.showSidebar])))
167
+ throw new Error(`Couldn't find "${UI.newChat}" or "${UI.showSidebar}" in ChatGPT.`);
168
+ await this.until(() => this.click([UI.newChat]), 5_000, `"${UI.newChat}" in the sidebar`);
169
+ }
170
+ await this.wait(300);
171
+ await this.hideSidebar();
172
+ await this.until(() => this.page('const c = composer(); return !!c && !c.textContent.trim() && !button(UI.stop);'), 10_000, 'a new chat');
173
+ }
174
+ /** Switches the new-chat screen to Chat or Work. Returns the mode it was in, or null when there's no switch. */
175
+ async setMode(mode) {
176
+ const find = () => this.page(`
177
+ const opts = buttons().filter((b) => [UI.chatMode, UI.workMode].includes(label(b)) && b.hasAttribute('aria-pressed'));
178
+ if (!opts.length) return null;
179
+ const cur = opts.find((b) => b.getAttribute('aria-pressed') === 'true');
180
+ const want = opts.find((b) => label(b) === ${JSON.stringify(mode)});
181
+ return { current: cur ? label(cur) : null, target: want && want.getAttribute('aria-pressed') !== 'true' ? center(want) : null };`);
182
+ const r = await this.until(find, 3_000, 'the Chat/Work switch').catch(() => null);
183
+ if (!r)
184
+ return null;
185
+ if (r.target) {
186
+ await this.clickAt(r.target);
187
+ await this.until(() => this.page(`return buttons().some((b) => label(b) === ${JSON.stringify(mode)} && b.getAttribute('aria-pressed') === 'true');`), 5_000, `${mode} mode`);
188
+ await this.wait(400);
189
+ }
190
+ return r.current;
191
+ }
192
+ async clearComposer() {
193
+ await this.page('const c = composer(); if (c) c.focus(); return true;');
194
+ await this.key('a', 'KeyA', 65, 4, undefined, ['selectAll']);
195
+ await this.key('Backspace', 'Backspace', 8);
196
+ }
197
+ /** Opens the + menu and returns the labels of entries that match the plugin's name. */
198
+ async pluginEntries(name) {
199
+ await this.page('window.__mcplaneBefore = new Set(document.querySelectorAll("button")); return true;');
200
+ if (!(await this.click([UI.addMenu])))
201
+ throw new Error(`Couldn't find "${UI.addMenu}" (the + button) in ChatGPT.`);
202
+ await this.wait(600);
203
+ return this.page(`
204
+ const before = window.__mcplaneBefore || new Set();
205
+ const want = ${JSON.stringify(name.toLowerCase())};
206
+ const items = [...document.querySelectorAll('button')].filter((b) => !before.has(b));
207
+ const parts = (b) => [...b.querySelectorAll('span, div')].map((s) => s.textContent.trim().toLowerCase());
208
+ let hits = items.filter((b) => parts(b).includes(want));
209
+ if (!hits.length) hits = items.filter((b) => label(b).toLowerCase().includes(want));
210
+ window.__mcplaneHits = hits;
211
+ return hits.map(label);`);
212
+ }
213
+ async pickEntry(i) {
214
+ await this.page(`const b = (window.__mcplaneHits || [])[${i}]; if (!b) return false; b.scrollIntoView({ block: 'nearest' }); b.click(); return true;`);
215
+ await this.wait(500);
216
+ return this.page(`
217
+ const c = composer();
218
+ if (!c) return '';
219
+ const p = c.querySelector('[' + UI.mentionAttr + ']');
220
+ if (p) return p.getAttribute(UI.mentionAttr) || 'mentioned';
221
+ return c.textContent.trim() ? 'mentioned' : '';`);
222
+ }
223
+ /**
224
+ * @mentions the plugin through the + menu, so the test talks to it. When several
225
+ * plugins share the name (the directory version and your developer-mode one),
226
+ * the developer-mode one wins, since that's what review wants to see.
227
+ */
228
+ async mention(name) {
229
+ // The plugin list can arrive after the menu does, so look again for a few seconds.
230
+ const deadline = Date.now() + 15_000;
231
+ let hits = await this.pluginEntries(name);
232
+ while (!hits.length && Date.now() < deadline) {
233
+ await this.key('Escape', 'Escape', 27);
234
+ await this.wait(1_000);
235
+ hits = await this.pluginEntries(name);
236
+ }
237
+ if (!hits.length) {
238
+ await this.key('Escape', 'Escape', 27);
239
+ return { ok: false, devMode: false, choices: 0 };
240
+ }
241
+ const order = this.pluginPick && this.pluginPick.index < hits.length ? [this.pluginPick.index, ...hits.keys()] : [...hits.keys()];
242
+ let first = null;
243
+ let menuOpen = true;
244
+ for (const i of [...new Set(order)]) {
245
+ if (!menuOpen) {
246
+ await this.clearComposer();
247
+ await this.pluginEntries(name);
248
+ }
249
+ const path = await this.pickEntry(i);
250
+ menuOpen = false;
251
+ if (!path)
252
+ continue;
253
+ first ??= { index: i, path };
254
+ if (path.includes(UI.devMode) || hits.length === 1 || this.pluginPick?.path === path) {
255
+ this.pluginPick = { index: i, path };
256
+ return { ok: true, devMode: path.includes(UI.devMode), choices: hits.length };
257
+ }
258
+ }
259
+ if (!first)
260
+ return { ok: false, devMode: false, choices: hits.length };
261
+ // None is in developer mode: use the first one.
262
+ await this.clearComposer();
263
+ await this.pluginEntries(name);
264
+ await this.pickEntry(first.index);
265
+ this.pluginPick = first;
266
+ return { ok: true, devMode: false, choices: hits.length };
267
+ }
268
+ /** Types like a person would, so the video shows the prompt being written. */
269
+ async type(text) {
270
+ await this.page('const c = composer(); if (!c) return false; if (!c.contains(document.activeElement)) c.focus(); return true;');
271
+ for (const ch of text.replace(/\s*\n\s*/g, ' ')) {
272
+ await this.cdp.send('Input.insertText', { text: ch });
273
+ await this.wait(18 + Math.round(Math.random() * 30));
274
+ }
275
+ }
276
+ async send() {
277
+ if (await this.click(UI.send))
278
+ return;
279
+ await this.key('Enter', 'Enter', 13, 0, '\r');
280
+ }
281
+ /**
282
+ * Waits for the answer to finish: the Stop button gone and the page still for a
283
+ * moment. Approval cards are clicked with --approve, otherwise left to you.
284
+ */
285
+ async waitForReply(o) {
286
+ const start = Date.now();
287
+ let busySeen = false;
288
+ let quietSince = 0;
289
+ let lastText = -1;
290
+ let told = false;
291
+ while (Date.now() - start < REPLY_TIMEOUT) {
292
+ const s = await this.page('const ls = buttons().map(label); return { busy: ls.some((l) => UI.stop.includes(l)), approval: ls.some((l) => UI.approval.includes(l)), text: document.body.innerText.length };');
293
+ const now = Date.now();
294
+ if (s.approval) {
295
+ quietSince = 0;
296
+ if (o.approve) {
297
+ if (await this.click(UI.allow))
298
+ o.log(' approved a tool call (Allow once)');
299
+ }
300
+ else if (!told) {
301
+ o.log(' ChatGPT is asking to run a tool: approve or deny it in the app (or run again with --approve)');
302
+ told = true;
303
+ }
304
+ }
305
+ else if (s.busy) {
306
+ busySeen = true;
307
+ quietSince = 0;
308
+ }
309
+ else {
310
+ if (s.text !== lastText || !quietSince)
311
+ quietSince = now;
312
+ if ((busySeen || now - start > 15_000) && now - quietSince >= 2_500)
313
+ return 'done';
314
+ }
315
+ lastText = s.text;
316
+ await this.wait(300);
317
+ }
318
+ return 'timeout';
319
+ }
320
+ /** Opens the "Worked for…" summary so the video shows which tools ran. */
321
+ async showActivity() {
322
+ const p = await this.page(`
323
+ const re = new RegExp(UI.activity);
324
+ const b = buttons().filter((b) => b.getAttribute('aria-expanded') === 'false' && re.test(label(b))).pop();
325
+ return b ? center(b) : null;`);
326
+ if (!p)
327
+ return false;
328
+ await this.clickAt(p);
329
+ return true;
330
+ }
331
+ /** Starts recording the window; the returned function stops it and hands back the frames. */
332
+ async record(work) {
333
+ const frames = [];
334
+ let offset = null;
335
+ const off = this.cdp.on('Page.screencastFrame', (p) => {
336
+ const now = Date.now() / 1000;
337
+ const shown = typeof p.metadata?.timestamp === 'number' ? p.metadata.timestamp : now;
338
+ // Keep the compositor's timing between frames, aligned to this clock.
339
+ offset ??= now - shown;
340
+ const file = `frames/f${String(++this.frameNo).padStart(6, '0')}.jpg`;
341
+ // Written before the ack, so ChatGPT never sends frames faster than they reach the disk.
342
+ writeFileSync(join(work, file), Buffer.from(p.data, 'base64'));
343
+ frames.push({ file, ts: shown + offset });
344
+ this.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }).catch(() => null);
345
+ });
346
+ await this.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 85, maxWidth: 1920, maxHeight: 1200, everyNthFrame: 1 });
347
+ return async () => {
348
+ await this.cdp.send('Page.stopScreencast').catch(() => null);
349
+ const end = Date.now() / 1000;
350
+ off();
351
+ return { frames, end };
352
+ };
353
+ }
354
+ }
355
+ /** "1,3" or "2-4" to zero-based indexes into the positive tests, in the order given. */
356
+ export function pickTests(count, only) {
357
+ if (!only)
358
+ return [...Array(count).keys()];
359
+ const picked = [];
360
+ for (const part of only.split(',').map((s) => s.trim()).filter(Boolean)) {
361
+ const m = part.match(/^(\d+)(?:-(\d+))?$/);
362
+ if (!m)
363
+ throw new Error(`--only takes test numbers like 1,3 or 2-4, not "${part}".`);
364
+ const [a, b] = [Number(m[1]), Number(m[2] ?? m[1])];
365
+ for (let n = a; n <= b; n++) {
366
+ if (n < 1 || n > count)
367
+ throw new Error(`There is no positive test ${n}; mcplane.json has ${count}.`);
368
+ if (!picked.includes(n - 1))
369
+ picked.push(n - 1);
370
+ }
371
+ }
372
+ if (!picked.length)
373
+ throw new Error('--only picked no tests.');
374
+ return picked;
375
+ }
376
+ const isRunning = () => spawnSync('pgrep', ['-f', `^${BINARY}`]).status === 0;
377
+ const osascript = (script) => spawnSync('osascript', ['-e', script], { stdio: 'ignore' });
378
+ async function confirm(question) {
379
+ if (!process.stdin.isTTY)
380
+ throw new Error('mcplane demo asks before it drives ChatGPT. Run it in a terminal, or pass --yes.');
381
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
382
+ try {
383
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
384
+ }
385
+ finally {
386
+ rl.close();
387
+ }
388
+ }
389
+ export async function demo(m, o = {}) {
390
+ const log = o.log ?? ((s) => console.log(s));
391
+ const dir = o.dir ?? process.cwd();
392
+ const port = o.port ?? PORT;
393
+ if (process.platform !== 'darwin')
394
+ throw new Error('mcplane demo records the ChatGPT desktop app, which runs on macOS.');
395
+ if (!existsSync(BINARY))
396
+ throw new Error(`mcplane demo needs the ChatGPT desktop app in ${APP}.`);
397
+ const ffmpeg = findFfmpeg();
398
+ if (!ffmpeg)
399
+ throw new Error('mcplane demo needs ffmpeg to make the video: brew install ffmpeg');
400
+ const all = m.tests?.positive ?? [];
401
+ if (!all.length)
402
+ throw new Error('mcplane.json has no tests.positive to record. The onboard prompt writes them.');
403
+ const tests = pickTests(all.length, o.only).map((i) => ({ n: i + 1, ...all[i] }));
404
+ const plugin = o.plugin ?? m.title;
405
+ const mode = o.work ? UI.workMode : UI.chatMode;
406
+ const tools = await listTools(m, o.token).catch(() => null);
407
+ const writes = new Set((tools ?? []).filter((t) => t.annotations?.readOnlyHint !== true).map((t) => t.name));
408
+ if (!o.yes) {
409
+ log(`mcplane demo drives ChatGPT on this Mac, signed in as you, and records its window.`);
410
+ log(`For each test it opens a new chat in ${mode} mode, @mentions ${plugin} and sends:`);
411
+ for (const t of tests) {
412
+ const w = t.tools.filter((n) => writes.has(n));
413
+ log(` ${t.n}. ${t.prompt}${w.length ? ` (calls ${w.join(', ')}, which writes)` : ''}`);
414
+ }
415
+ if (!tools)
416
+ log(`(Couldn't read the server's tools, so tests that write aren't marked.)`);
417
+ log('The sidebar, with your chats and name, is hidden before recording. The chats stay in your history.');
418
+ log('Answers can draw on your ChatGPT memory and past chats, so watch the video before you share it.');
419
+ log(o.approve ? 'If ChatGPT asks to run a tool, mcplane clicks "Allow once".' : 'If ChatGPT asks to run a tool, you approve or deny it in the app.');
420
+ if (!(await confirm('Continue? (y/N) ')))
421
+ throw new Error('Nothing recorded.');
422
+ }
423
+ const ac = new AbortController();
424
+ const stop = () => ac.abort(new Error('Stopped.'));
425
+ process.once('SIGINT', stop);
426
+ const work = await mkdtemp(join(tmpdir(), 'mcplane-demo-'));
427
+ await mkdir(join(work, 'frames'));
428
+ const notes = [];
429
+ const scenes = [];
430
+ let launched = false;
431
+ let cdp = null;
432
+ let recorded = false;
433
+ try {
434
+ // Attach to a ChatGPT already listening on the port, or start one that does.
435
+ const existing = await listTargets(port).catch(() => null);
436
+ if (existing && !pickMainWindow(existing, MAIN_WINDOW))
437
+ throw new Error(`Port ${port} is in use by something other than ChatGPT. Pass --port to use another.`);
438
+ if (!existing) {
439
+ if (isRunning())
440
+ throw new Error('ChatGPT is open without a debugging port. Quit it (Cmd+Q) and run mcplane demo again: it reopens ChatGPT with one, and quits it afterwards.');
441
+ log('Opening ChatGPT…');
442
+ // Electron refuses some NODE_OPTIONS (e.g. --openssl-legacy-provider) and crashes on start.
443
+ const env = { ...process.env };
444
+ delete env.NODE_OPTIONS;
445
+ spawn(BINARY, [`--remote-debugging-port=${port}`, ...KEEP_RENDERING], { env, detached: true, stdio: 'ignore' }).unref();
446
+ launched = true;
447
+ }
448
+ const deadline = Date.now() + (launched ? 60_000 : 5_000);
449
+ let target = existing ? pickMainWindow(existing, MAIN_WINDOW) : undefined;
450
+ while (!target) {
451
+ if (Date.now() > deadline)
452
+ throw new Error("ChatGPT's window didn't appear.");
453
+ await sleep(500, undefined, { signal: ac.signal });
454
+ target = pickMainWindow((await listTargets(port).catch(() => null)) ?? [], MAIN_WINDOW);
455
+ }
456
+ cdp = await Cdp.connect(target.webSocketDebuggerUrl);
457
+ osascript(`tell application "${APP}" to activate`);
458
+ const app = new Desktop(cdp, ac.signal);
459
+ await app.ready();
460
+ const sidebarWasOpen = await app.sidebarOpen();
461
+ let modeWas = null;
462
+ let devModeNoted = false;
463
+ try {
464
+ for (const [i, t] of tests.entries()) {
465
+ log(`${i + 1}/${tests.length} ${t.scenario}`);
466
+ await app.newChat();
467
+ const was = await app.setMode(mode);
468
+ if (i === 0)
469
+ modeWas = was;
470
+ const mention = await app.mention(plugin);
471
+ // Without the @mention ChatGPT may answer from the web instead, and the video shows nothing of yours.
472
+ if (!mention.ok)
473
+ throw new Error(`There's no plugin called "${plugin}" in ChatGPT's + menu, so nothing was sent. Connect it in developer mode, or pass --plugin with the name ChatGPT shows.`);
474
+ if (!mention.devMode && !devModeNoted) {
475
+ notes.push(`@${plugin} isn't a developer-mode plugin${mention.choices > 1 ? ' (none of the ones with that name are)' : ''}. Review asks for a recording in developer mode.`);
476
+ devModeNoted = true;
477
+ }
478
+ if (await app.sidebarOpen())
479
+ await app.hideSidebar(); // never record the sidebar
480
+ const finish = await app.record(work);
481
+ let result = 'timeout';
482
+ try {
483
+ await sleep(1_200, undefined, { signal: ac.signal });
484
+ await app.type(t.prompt);
485
+ await sleep(500, undefined, { signal: ac.signal });
486
+ await app.send();
487
+ const sent = Date.now();
488
+ result = await app.waitForReply({ approve: !!o.approve, log });
489
+ log(result === 'done' ? ` answered in ${Math.round((Date.now() - sent) / 1000)}s` : ` still going after ${REPLY_TIMEOUT / 60_000} minutes; moving on`);
490
+ await sleep(800, undefined, { signal: ac.signal });
491
+ if (await app.showActivity())
492
+ await sleep(600, undefined, { signal: ac.signal });
493
+ await sleep(2_500, undefined, { signal: ac.signal });
494
+ }
495
+ finally {
496
+ const { frames, end } = await finish();
497
+ scenes.push({ caption: t.scenario, frames, end });
498
+ }
499
+ if (result === 'timeout')
500
+ notes.push(`Test ${t.n} was still running after ${REPLY_TIMEOUT / 60_000} minutes; its scene ends there.`);
501
+ }
502
+ recorded = true;
503
+ }
504
+ finally {
505
+ // Put the window back the way it was: mode, then sidebar.
506
+ if (!ac.signal.aborted) {
507
+ try {
508
+ if (modeWas && modeWas !== mode) {
509
+ await app.newChat();
510
+ await app.setMode(modeWas);
511
+ }
512
+ if (sidebarWasOpen)
513
+ await app.showSidebar();
514
+ }
515
+ catch {
516
+ // Cosmetic; never fail a recording over it.
517
+ }
518
+ }
519
+ }
520
+ }
521
+ catch (e) {
522
+ throw ac.signal.aborted ? new Error('Stopped. Nothing was saved.') : e;
523
+ }
524
+ finally {
525
+ process.removeListener('SIGINT', stop);
526
+ cdp?.close();
527
+ if (launched && !o.keepOpen) {
528
+ osascript(`quit app "${APP}"`);
529
+ for (let i = 0; i < 30 && isRunning(); i++)
530
+ await sleep(500);
531
+ if (isRunning())
532
+ log(`ChatGPT is still running with its debugging port (${port}) open. Quit it yourself.`);
533
+ }
534
+ else if (launched || cdp) {
535
+ log(`ChatGPT is still running with its debugging port (${port}) open, so any program on this Mac can drive it. Quit it when you're done.`);
536
+ }
537
+ if (!recorded)
538
+ await rm(work, { recursive: true, force: true });
539
+ }
540
+ try {
541
+ const first = scenes.find((s) => s.frames.length)?.frames[0];
542
+ if (!first)
543
+ throw new Error('No frames were recorded.');
544
+ const size = jpegSize(await readFile(join(work, first.file)));
545
+ if (!size)
546
+ throw new Error(`Couldn't read the size of ${first.file}.`);
547
+ const out = resolve(dir, o.out ?? '.mcplane/packs/chatgpt/demo.mp4');
548
+ if (!o.out)
549
+ await ensureLocalDir(dir);
550
+ await mkdir(dirname(out), { recursive: true });
551
+ const seconds = await buildVideo({
552
+ scenes: scenes.filter((s) => s.frames.length),
553
+ title: `${m.title} in ChatGPT`,
554
+ subtitle: m.subtitle ?? m.oneLiner,
555
+ out,
556
+ work,
557
+ ffmpeg,
558
+ size: outputSize(size.width, size.height),
559
+ });
560
+ await rm(work, { recursive: true, force: true });
561
+ if (tools) {
562
+ const shown = new Set(tests.flatMap((t) => t.tools));
563
+ const missing = tools.map((t) => t.name).filter((n) => !shown.has(n));
564
+ if (missing.length)
565
+ notes.push(`Not shown: ${missing.join(', ')}. Review wants the video to cover the tools your tests use.`);
566
+ }
567
+ return { out, seconds, scenes: scenes.length, notes };
568
+ }
569
+ catch (e) {
570
+ throw new Error(`${e.message} The recorded frames are in ${work}; delete it when you're done.`);
571
+ }
572
+ }
573
+ export const _test = { pickTests, PAGE };
package/dist/drift.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * the live server and mcplane.json against it.
6
6
  *
7
7
  * Every store calls your live server, so behaviour fixes reach users on their
8
- * own. What doesn't: listing text, tool names in Claude's listing, hint
9
- * justifications, registry versions and plugins pinned to a commit.
8
+ * own. What doesn't: listing text, tool names in Claude's listing, registry
9
+ * versions and plugins pinned to a commit.
10
10
  */
11
11
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { resolve } from 'node:path';
@@ -172,7 +172,7 @@ export async function drift(m, stores, opts = {}, dir = process.cwd()) {
172
172
  todo: `No resubmission needed for tools: OpenAI rescans your server, drops removed tools at once and switches new or changed ones over after they pass automated checks (the old definition stays live until then).${d.added.length || d.removed.length ? ' Update the test cases and demo video in your next version.' : ''}`,
173
173
  });
174
174
  if (hintChanges.length)
175
- items.push({ store, level: 'action', what: `Hints changed: ${hintChanges.map((c) => c.tool).join(', ')}`, todo: 'Your published version’s justifications explain the old values. Put new ones in the next version ("mcplane pack chatgpt").' });
175
+ items.push({ store, level: 'info', what: `Hints changed: ${hintChanges.map((c) => c.tool).join(', ')}`, todo: 'Nothing to resubmit: OpenAI re-checks changed annotations automatically and no longer asks for justifications. If its review flags a value you believe is right, appeal with an explanation.' });
176
176
  if (listingChange.length)
177
177
  items.push({ store, level: 'action', what: `Listing text changed (${listingChange.join(', ')})`, todo: 'Listing changes need a new version, review and publication: "mcplane pack chatgpt", import it into a new version, submit.' });
178
178
  }
@@ -203,6 +203,13 @@ export async function drift(m, stores, opts = {}, dir = process.cwd()) {
203
203
  if (store === 'mcp-registry' && (toolChange || listingChange.length)) {
204
204
  items.push({ store, level: 'action', what: `Changed ${since}`, todo: 'Bump the version and run "mcplane publish mcp-registry".' });
205
205
  }
206
+ if (store === 'vercel-connect') {
207
+ // Connect brokers credentials and lists services, not tools; a listing change goes back through review.
208
+ if (listingChange.length)
209
+ items.push({ store, level: 'action', what: `Listing text changed (${listingChange.join(', ')})`, todo: 'Edit and Resubmit from Submit a Service ("mcplane pack vercel-connect" has the values). A changed OAuth method or discovery document needs a new test connector and token first.' });
210
+ if (toolChange)
211
+ items.push({ store, level: 'info', what: `Tools changed ${since}: ${summary}`, todo: 'Nothing to resubmit: Vercel Connect hands out tokens for your server and doesn’t list its tools.' });
212
+ }
206
213
  }
207
214
  return { items, missing };
208
215
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * An eval suite for "claude plugin eval", built from mcplane.json's review tests, so the Claude
3
+ * Code plugin that ships your MCP server is scored against a no-plugin baseline and can gate CI.
4
+ * Each positive test becomes a case that should call the tools it names and give the expected
5
+ * answer; each negative test a case that must not call the server at all. Tools answer from mocks
6
+ * built from your live tools/list, so a run needs no network or sign-in, and writes nothing.
7
+ *
8
+ * Format, from https://code.claude.com/docs/en/plugin-evals:
9
+ * evals/<case>/prompt.md frontmatter: run fields (unknown keys are errors); body: the prompt
10
+ * evals/<case>/graders/<name>.md frontmatter: type and its options, weight, arm; body: an llm rubric
11
+ * evals/<case>/mocks/<server>/ this case's mocks, overriding the suite's file by file
12
+ * evals/mocks/<server>/<tool>.md frontmatter: type (fixed | agent); body: the result or instructions
13
+ * evals/mocks/<server>/_tools.json a saved tools/list response, for real descriptions and schemas
14
+ */
15
+ import type { Manifest, Tool } from './types.js';
16
+ export interface EvalPlugin {
17
+ /** The plugin's name in its plugin.json. */
18
+ plugin: string;
19
+ /** The server's key in the plugin's MCP configuration (.mcp.json or plugin.json mcpServers). */
20
+ server: string;
21
+ }
22
+ /** How Claude Code names a plugin's MCP tools. */
23
+ export declare const toolId: (p: EvalPlugin, tool: string) => string;
24
+ /** Case directory names: stable, ordered and readable, cut at a word. */
25
+ export declare const caseSlug: (s: string) => string;
26
+ /** Markdown with YAML frontmatter. Strings go out as JSON, which YAML reads as quoted scalars, so quotes and colons can't break it. */
27
+ export declare function frontmatter(fields: Record<string, unknown>, body?: string): string;
28
+ export declare function evalSuite(m: Manifest, p: EvalPlugin, tools: Tool[]): {
29
+ files: {
30
+ path: string;
31
+ content: string;
32
+ }[];
33
+ problems: string[];
34
+ };