gogcli-mcp 2.26.0 → 2.27.1

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.
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "2.26.0",
6
+ "version": "2.27.1",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -112,6 +112,14 @@
112
112
  "name": "gog_gmail_send",
113
113
  "description": "Send an email, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
114
114
  },
115
+ {
116
+ "name": "gog_gmail_reply",
117
+ "description": "Reply to a Gmail message (sender only), inheriting the \"Re:\" subject and quoting the original below your body"
118
+ },
119
+ {
120
+ "name": "gog_gmail_reply_all",
121
+ "description": "Reply to every participant of a Gmail message (sender plus all To/Cc), with the same inherited subject and quoted original"
122
+ },
115
123
  {
116
124
  "name": "gog_gmail_run",
117
125
  "description": "Run any gog gmail subcommand (escape hatch)"
package/mint.yaml CHANGED
@@ -86,7 +86,7 @@ dependencies:
86
86
  # nine pins too.
87
87
  - kind: github-release
88
88
  repo: openclaw/gogcli
89
- tag: v0.38.1
89
+ tag: v0.38.3
90
90
  asset: "gogcli_*_linux_amd64.tar.gz"
91
91
  bin: [gog]
92
92
  state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.26.0",
3
+ "version": "2.27.1",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -41,12 +41,12 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^0.15.0",
44
+ "@chrischall/mcp-utils": "^0.17.1",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "zod": "^4.4.3"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.2.0",
49
+ "@types/node": "^26.4.0",
50
50
  "@vitest/coverage-v8": "^4.1.8",
51
51
  "esbuild": "^0.28.2",
52
52
  "typescript": "^7.0.2",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.26.0",
10
+ "version": "2.27.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.26.0",
15
+ "version": "2.27.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
package/src/lib.ts CHANGED
@@ -16,6 +16,11 @@ export {
16
16
  registerSlidesTools,
17
17
  registerTasksTools,
18
18
  } from './server.js';
19
+ // The reply/reply-all schema and flag builder live in the base package so the
20
+ // gmail sub-package's draft-side twins reuse ONE definition — registering the
21
+ // same tool name from both registrar lists would be a duplicate-name error.
22
+ export { replySchema, appendReplyFlags } from './tools/gmail.js';
23
+ export type { ReplyFlags } from './tools/gmail.js';
19
24
  export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
20
25
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
21
26
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
@@ -57,4 +62,5 @@ export {
57
62
  pageAliasParam,
58
63
  resolvePageToken,
59
64
  registerRunTool,
65
+ assertNotBoth,
60
66
  } from './tools/utils.js';
package/src/runner.ts CHANGED
@@ -217,7 +217,7 @@ const TIMEOUT_MS = 30_000;
217
217
  // so the requirement change is surfaced in the release notes (see
218
218
  // .github/release.yml). This is the single source of truth for the required
219
219
  // version; keep the README/CLAUDE.md mention in sync.
220
- export const MIN_GOG_VERSION = '0.38.1';
220
+ export const MIN_GOG_VERSION = '0.38.3';
221
221
 
222
222
  // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
223
223
  // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
@@ -1,9 +1,86 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
3
+ import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken, assertNotBoth } from './utils.js';
4
4
  import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
5
5
  import type { GogArg } from '../runner.js';
6
6
  import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
7
+ import type { InlineAttachmentInput } from '../attachments.js';
8
+
9
+ // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
10
+ // only in the subcommand and default recipient set (reply → sender; reply-all
11
+ // → every participant). Recipient flags are repeatable on the CLI, so they are
12
+ // arrays here. --to/--cc/--bcc ADD or MOVE recipients onto the inherited reply
13
+ // set; --remove drops them. Body/HTML follow the same inline-or-file shape as
14
+ // the draft tools.
15
+ export const replySchema = {
16
+ messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header.'),
17
+ body: z.string().optional().describe('Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
18
+ bodyHtml: z.string().optional().describe('Reply body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
19
+ bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body. gog also accepts "-" for stdin, but this server never writes to gog\'s stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
20
+ to: z.array(z.string()).optional().describe('Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message.'),
21
+ cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
22
+ bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
23
+ remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
24
+ subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
25
+ noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
26
+ attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
27
+ attachInline: attachInlineParam,
28
+ from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
29
+ autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
30
+ signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
31
+ signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
32
+ signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
33
+ account: accountParam,
34
+ };
35
+
36
+ export type ReplyFlags = {
37
+ body?: string;
38
+ bodyHtml?: string;
39
+ bodyHtmlFile?: string;
40
+ to?: string[];
41
+ cc?: string[];
42
+ bcc?: string[];
43
+ remove?: string[];
44
+ subject?: string;
45
+ noQuote?: boolean;
46
+ attach?: string[];
47
+ attachInline?: InlineAttachmentInput[];
48
+ from?: string;
49
+ autoFromAddressedAlias?: boolean;
50
+ signature?: boolean;
51
+ signatureFrom?: string;
52
+ signatureFile?: string;
53
+ };
54
+
55
+ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
56
+ assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
57
+ if (f.body) args.push(payloadArg('body', 'body-file', f.body));
58
+ if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
59
+ else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
60
+ if (f.to) for (const r of f.to) args.push(`--to=${r}`);
61
+ if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
62
+ if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
63
+ if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
64
+ if (f.subject) args.push(`--subject=${f.subject}`);
65
+ if (f.noQuote) args.push('--no-quote');
66
+ if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
67
+ // Same repeatable --attach flag, but the bytes travel with the call: the
68
+ // executor writes each one to a temp file beside gog and passes that path.
69
+ // This is the only attachment route that works when the caller and gog do not
70
+ // share a filesystem (hosted connector, GOG_RUNNER_URL backend). `args` is
71
+ // passed so the size check sees the body too, which shares the same budget
72
+ // once payloadArg has turned it into a file arg.
73
+ args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
74
+ if (f.from) args.push(`--from=${f.from}`);
75
+ if (f.signature) args.push('--signature');
76
+ if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
77
+ if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
78
+ // PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
79
+ // silently changes which address the mail goes out FROM, with nothing in the arg
80
+ // array to show for it — and the remote runner's backend env is not ours to set.
81
+ // An explicit flag is the only value authoritative on both transports.
82
+ args.push(f.autoFromAddressedAlias ? '--auto-from-addressed-alias' : '--auto-from-addressed-alias=false');
83
+ }
7
84
 
8
85
  export function registerGmailTools(server: McpServer): void {
9
86
  server.registerTool('gog_gmail_search', {
@@ -73,7 +150,10 @@ export function registerGmailTools(server: McpServer): void {
73
150
  + 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
74
151
  + 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
75
152
  + '"no such file or directory". When either is used, the JSON result echoes the attached filenames '
76
- + 'and byte sizes — check it to confirm the files were embedded.',
153
+ + 'and byte sizes — check it to confirm the files were embedded. '
154
+ + 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
155
+ + 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
156
+ + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
77
157
  annotations: { destructiveHint: true },
78
158
  inputSchema: {
79
159
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -81,13 +161,14 @@ export function registerGmailTools(server: McpServer): void {
81
161
  body: z.string().describe('Email body (plain text). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
82
162
  cc: z.string().optional().describe('CC recipients, comma-separated'),
83
163
  bcc: z.string().optional().describe('BCC recipients, comma-separated'),
84
- replyToMessageId: z.string().optional().describe('Message ID to reply to'),
85
- threadId: z.string().optional().describe('Thread ID to reply within'),
164
+ replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
165
+ threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
166
+ quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
86
167
  attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
87
168
  attachInline: attachInlineParam,
88
169
  account: accountParam,
89
170
  },
90
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
171
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }) => {
91
172
  // A long body cannot ride in argv: the hosted runner caps a single arg and
92
173
  // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
93
174
  // past the shared threshold; the executor materializes the temp file.
@@ -96,6 +177,10 @@ export function registerGmailTools(server: McpServer): void {
96
177
  if (bcc) args.push(`--bcc=${bcc}`);
97
178
  if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
98
179
  if (threadId) args.push(`--thread-id=${threadId}`);
180
+ // gog's --quote on `gmail send` is opt-in (a plain bool defaulting false),
181
+ // the mirror image of `gmail reply`, where quoting is the default and
182
+ // --no-quote opts out. Nothing here can be inferred from the reply target.
183
+ if (quote) args.push('--quote');
99
184
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
100
185
  // Same repeatable --attach flag; the executor materializes each payload to a
101
186
  // temp file beside gog and substitutes its path. `args` is passed so the
@@ -106,5 +191,51 @@ export function registerGmailTools(server: McpServer): void {
106
191
  return runOrDiagnose(args, { account });
107
192
  });
108
193
 
194
+ // ==========================================================================
195
+ // REPLY / REPLY-ALL
196
+ //
197
+ // These live here, in the base package, because gog_gmail_send +
198
+ // replyToMessageId is NOT a reply. It sets In-Reply-To/References — so Gmail
199
+ // files it in the right thread — and stops there: no quoted original, no
200
+ // inherited "Re:" subject, no inherited recipients. To anyone reading the
201
+ // body it arrives as a brand-new message.
202
+ //
203
+ // The asymmetry is gog's: `gmail reply` quotes BY DEFAULT (opt out with
204
+ // --no-quote), while `gmail send` quotes only on an explicit --quote
205
+ // (internal/cmd/gmail_send.go, a plain bool defaulting false). The gmail
206
+ // sub-package reuses replySchema/appendReplyFlags for its draft-side twins
207
+ // rather than declaring a second copy — registering these tools twice in the
208
+ // one server would be a duplicate-name error.
209
+ // ==========================================================================
210
+ server.registerTool('gog_gmail_reply', {
211
+ description:
212
+ 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
213
+ + 'answering a message: it threads off the original AND inherits its "Re:" subject and quotes its body below '
214
+ + 'yours, which gog_gmail_send does not — a send with replyToMessageId lands in the right thread but reads as a '
215
+ + 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
216
+ + 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
217
+ + 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
218
+ + 'instead of sending it.',
219
+ annotations: { destructiveHint: true },
220
+ inputSchema: replySchema,
221
+ }, async ({ messageId, account, ...flags }) => {
222
+ const args: GogArg[] = ['gmail', 'reply', messageId];
223
+ appendReplyFlags(args, flags);
224
+ return runOrDiagnose(args, { account });
225
+ });
226
+
227
+ server.registerTool('gog_gmail_reply_all', {
228
+ description:
229
+ 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
230
+ + 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
231
+ + 'reply-all. To stage it as a draft rather than send it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only).',
232
+ annotations: { destructiveHint: true },
233
+ inputSchema: replySchema,
234
+ }, async ({ messageId, account, ...flags }) => {
235
+ const args: GogArg[] = ['gmail', 'reply-all', messageId];
236
+ appendReplyFlags(args, flags);
237
+ return runOrDiagnose(args, { account });
238
+ });
239
+
109
240
  registerRunTool(server, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
110
241
  }
@@ -441,3 +441,23 @@ export function formatAuthHealth(raw: string, now: number): string {
441
441
  }
442
442
  return accounts.map((a) => formatOneAccountHealth(a, now)).join('\n\n');
443
443
  }
444
+
445
+ // gog rejects an inline flag together with its --*-file twin — `gmail drafts
446
+ // create` errors with "use only one of --body-html or --body-html-file", and
447
+ // `gmail forward` does the same for --note (misreporting it as --body). Catch
448
+ // the conflict here so the caller gets a message naming the TOOL params it
449
+ // actually passed, instead of a gog error naming flags it never saw.
450
+ export function assertNotBoth(
451
+ inlineParam: string,
452
+ fileParam: string,
453
+ inlineValue: string | undefined,
454
+ fileValue: string | undefined,
455
+ ): void {
456
+ if (inlineValue !== undefined && fileValue !== undefined) {
457
+ throw new Error(
458
+ `${inlineParam} and ${fileParam} are mutually exclusive — gog accepts only one of them. ` +
459
+ `Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), ` +
460
+ `or ${fileParam} with a path that already exists on the gog server.`,
461
+ );
462
+ }
463
+ }
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.26.0'; // x-release-please-version
41
+ const VERSION = '2.27.1'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -43,3 +43,68 @@ describe('VERSION', () => {
43
43
  expect(VERSION).toBe('0.0.0');
44
44
  });
45
45
  });
46
+
47
+ // The tool counts in README.md, SKILL.md and manifest.json are hand-maintained
48
+ // and have now drifted twice (0ec3470 "correct the stale tool counts", then
49
+ // again when reply/reply-all landed). They are the first thing a reader sees,
50
+ // so derive the truth from the registrars and fail the build on a mismatch
51
+ // rather than catching it in review a release later.
52
+ describe('published tool counts match the registrars', () => {
53
+ const readPkgFile = async (name: string) => {
54
+ const { readFile } = await import('node:fs/promises');
55
+ const { fileURLToPath } = await import('node:url');
56
+ return readFile(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
57
+ };
58
+
59
+ const liveToolNames = async (): Promise<string[]> => {
60
+ const harness = await createTestHarness((server) => {
61
+ for (const register of BASE_TOOL_REGISTRARS) register(server, undefined);
62
+ });
63
+ const names = (await harness.listTools()).map((t) => t.name);
64
+ await harness.close();
65
+ return names;
66
+ };
67
+
68
+ it('manifest.json lists exactly the registered tools', async () => {
69
+ const names = await liveToolNames();
70
+ const manifest = JSON.parse(await readPkgFile('manifest.json')) as { tools: Array<{ name: string }> };
71
+ expect([...manifest.tools.map((t) => t.name)].sort()).toEqual([...names].sort());
72
+ });
73
+
74
+ it.each(['README.md', 'SKILL.md'])('%s states the real total', async (file) => {
75
+ const total = (await liveToolNames()).length;
76
+ const text = await readPkgFile(file);
77
+ const heading = /^## Tools \((\d+)\)$/m.exec(text);
78
+ expect(heading, `${file} has no "## Tools (N)" heading`).not.toBeNull();
79
+ expect(Number(heading![1])).toBe(total);
80
+ // README also states the count in its opening paragraph.
81
+ const prose = /Includes (\d+) tools across/.exec(text);
82
+ if (prose) expect(Number(prose[1])).toBe(total);
83
+ });
84
+
85
+ it.each(['README.md', 'SKILL.md'])('%s states the real per-service counts', async (file) => {
86
+ const names = await liveToolNames();
87
+ const text = await readPkgFile(file);
88
+ // README: `| **Gmail** | 6 | …` SKILL: `| **Gmail** (6) | …`
89
+ const rows = [...text.matchAll(/^\| \*\*(.+?)\*\*(?: \((\d+)\)| \| (\d+))? \|/gm)];
90
+ expect(rows.length).toBeGreaterThan(0);
91
+ const service = (row: string) => row.toLowerCase().replace(/[^a-z]/g, '');
92
+ // Map a table label onto the gog_<service>_ prefix its tools carry.
93
+ // Only labels whose normalised form differs from their tool prefix belong
94
+ // here. "Apps Script" normalises to `appsscript` but the tools are
95
+ // `gog_appscript_`, so that mapping is load-bearing. A `discoveryapi` entry
96
+ // was not: both tables label the row "API", which normalises to `api` and
97
+ // already resolves — so it never matched and only implied a label that
98
+ // does not exist.
99
+ const prefixFor: Record<string, string> = {
100
+ appsscript: 'appscript',
101
+ };
102
+ for (const row of rows) {
103
+ const stated = Number(row[2] ?? row[3]);
104
+ if (!Number.isFinite(stated)) continue;
105
+ const key = service(row[1]);
106
+ const prefix = `gog_${prefixFor[key] ?? key}_`;
107
+ expect(names.filter((n) => n.startsWith(prefix)).length, `${file} row ${row[1]}`).toBe(stated);
108
+ }
109
+ });
110
+ });