gogcli-mcp 1.0.3 → 1.0.5

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/.mcp.json CHANGED
@@ -6,8 +6,7 @@
6
6
  "cwd": "/Users/chris/git/gogcli-mcp",
7
7
  "env": {
8
8
  "GOG_ACCOUNT": "${GOG_ACCOUNT}",
9
- "GOG_PATH": "${GOG_PATH}",
10
- "GOG_ACCESS_TOKEN": "${GOG_ACCESS_TOKEN}"
9
+ "GOG_PATH": "${GOG_PATH}"
11
10
  }
12
11
  }
13
12
  }
package/CLAUDE.md CHANGED
@@ -31,7 +31,7 @@ Main is always one version ahead of the latest tag. To release, run the **Tag &
31
31
  4. Rebuilds, commits, and pushes main + tag
32
32
  5. The tag push triggers the **Release** workflow (CI + npm publish + .mcpb + .skill + GitHub release)
33
33
 
34
- Do NOT manually bump versions or create tags unless the user explicitly asks.
34
+ Do NOT manually bump versions or create tags unless the user explicitly asks. Always prefer the Tag & Bump action: `gh workflow run tag-and-bump.yml --ref main`. The action handles all four version files, tagging, and triggering the release.
35
35
 
36
36
  ## Architecture
37
37
 
package/dist/index.js CHANGED
@@ -30137,9 +30137,10 @@ async function run(args, options = {}) {
30137
30137
  fullArgs.push(...args);
30138
30138
  const effectiveTimeout = timeout ?? TIMEOUT_MS;
30139
30139
  return new Promise((resolve, reject) => {
30140
- const child = spawner(process.env.GOG_PATH ?? "gog", fullArgs, { env: process.env });
30141
- let stdout = "";
30142
- let stderr = "";
30140
+ const { GOG_ACCESS_TOKEN: _, ...cleanEnv } = process.env;
30141
+ const child = spawner(process.env.GOG_PATH ?? "gog", fullArgs, { env: cleanEnv });
30142
+ const stdoutChunks = [];
30143
+ const stderrChunks = [];
30143
30144
  let settled = false;
30144
30145
  const timer = setTimeout(() => {
30145
30146
  settled = true;
@@ -30147,23 +30148,25 @@ async function run(args, options = {}) {
30147
30148
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
30148
30149
  }, effectiveTimeout);
30149
30150
  child.stdout.on("data", (chunk) => {
30150
- stdout += chunk.toString();
30151
+ stdoutChunks.push(chunk);
30151
30152
  });
30152
30153
  child.stderr.on("data", (chunk) => {
30153
- stderr += chunk.toString();
30154
+ stderrChunks.push(chunk);
30154
30155
  });
30155
30156
  child.on("close", (code) => {
30156
30157
  clearTimeout(timer);
30157
30158
  if (settled) return;
30158
30159
  settled = true;
30160
+ const stdout = Buffer.concat(stdoutChunks).toString();
30161
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
30159
30162
  if (code === 0) {
30160
- if (interactive && stderr.trim()) {
30163
+ if (interactive && stderr) {
30161
30164
  resolve(stdout + "\n" + stderr);
30162
30165
  } else {
30163
30166
  resolve(stdout);
30164
30167
  }
30165
30168
  } else {
30166
- reject(new Error(stderr.trim() || `gog exited with code ${code}`));
30169
+ reject(new Error(stderr || `gog exited with code ${code}`));
30167
30170
  }
30168
30171
  });
30169
30172
  child.on("error", (err) => {
@@ -30185,19 +30188,24 @@ function toText(output) {
30185
30188
  function toError(err) {
30186
30189
  return toText(err instanceof Error ? `Error: ${err.message}` : String(err));
30187
30190
  }
30191
+ var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
30192
+ var AUTH_HINT = "\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. Ask the user if they would like to re-authenticate.";
30188
30193
  async function runOrDiagnose(args, options) {
30189
30194
  try {
30190
30195
  return toText(await run(args, options));
30191
30196
  } catch (err) {
30192
30197
  const base = toError(err);
30198
+ const errText = base.content[0].text;
30199
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
30200
+ const hint = isAuthError ? AUTH_HINT : "";
30193
30201
  try {
30194
30202
  const accounts = await run(["auth", "list"]);
30195
- return toText(`${base.content[0].text}
30203
+ return toText(`${errText}
30196
30204
 
30197
30205
  Configured accounts:
30198
- ${accounts}`);
30206
+ ${accounts}${hint}`);
30199
30207
  } catch {
30200
- return base;
30208
+ return toText(`${errText}${hint}`);
30201
30209
  }
30202
30210
  }
30203
30211
  }
@@ -30264,11 +30272,7 @@ function registerAuthTools(server2) {
30264
30272
  args: external_exports3.array(external_exports3.string()).describe("Additional positional args and flags")
30265
30273
  }
30266
30274
  }, async ({ subcommand, args }) => {
30267
- try {
30268
- return toText(await run(["auth", subcommand, ...args]));
30269
- } catch (err) {
30270
- return toError(err);
30271
- }
30275
+ return runOrDiagnose(["auth", subcommand, ...args], {});
30272
30276
  });
30273
30277
  }
30274
30278
 
@@ -30481,7 +30485,6 @@ function registerDocsTools(server2) {
30481
30485
  });
30482
30486
  server2.registerTool("gog_docs_create", {
30483
30487
  description: "Create a new Google Doc. Returns JSON with the new docId and URL.",
30484
- annotations: { destructiveHint: false },
30485
30488
  inputSchema: {
30486
30489
  title: external_exports3.string().describe("Title for the new document"),
30487
30490
  account: accountParam
@@ -30525,6 +30528,79 @@ function registerDocsTools(server2) {
30525
30528
  }, async ({ docId, account }) => {
30526
30529
  return runOrDiagnose(["docs", "structure", docId], { account });
30527
30530
  });
30531
+ server2.registerTool("gog_docs_comments_list", {
30532
+ description: "List comments on a Google Doc. Returns open comments by default; set includeResolved=true to include resolved comments.",
30533
+ annotations: { readOnlyHint: true },
30534
+ inputSchema: {
30535
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30536
+ includeResolved: external_exports3.boolean().optional().describe("Include resolved comments (default: false, open only)"),
30537
+ account: accountParam
30538
+ }
30539
+ }, async ({ docId, includeResolved, account }) => {
30540
+ const args = ["docs", "comments", "list", docId];
30541
+ if (includeResolved) args.push("--include-resolved");
30542
+ return runOrDiagnose(args, { account });
30543
+ });
30544
+ server2.registerTool("gog_docs_comments_get", {
30545
+ description: "Get a single comment by ID, including its replies.",
30546
+ annotations: { readOnlyHint: true },
30547
+ inputSchema: {
30548
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30549
+ commentId: external_exports3.string().describe("Comment ID"),
30550
+ account: accountParam
30551
+ }
30552
+ }, async ({ docId, commentId, account }) => {
30553
+ return runOrDiagnose(["docs", "comments", "get", docId, commentId], { account });
30554
+ });
30555
+ server2.registerTool("gog_docs_comments_add", {
30556
+ description: "Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI.",
30557
+ inputSchema: {
30558
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30559
+ content: external_exports3.string().describe("Comment text"),
30560
+ quoted: external_exports3.string().optional().describe("Quoted text to attach to the comment (shown in UIs when available)"),
30561
+ account: accountParam
30562
+ }
30563
+ }, async ({ docId, content, quoted, account }) => {
30564
+ const args = ["docs", "comments", "add", docId, content];
30565
+ if (quoted) args.push(`--quoted=${quoted}`);
30566
+ return runOrDiagnose(args, { account });
30567
+ });
30568
+ server2.registerTool("gog_docs_comments_reply", {
30569
+ description: "Reply to an existing comment on a Google Doc.",
30570
+ inputSchema: {
30571
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30572
+ commentId: external_exports3.string().describe("Comment ID to reply to"),
30573
+ content: external_exports3.string().describe("Reply text"),
30574
+ account: accountParam
30575
+ }
30576
+ }, async ({ docId, commentId, content, account }) => {
30577
+ return runOrDiagnose(["docs", "comments", "reply", docId, commentId, content], { account });
30578
+ });
30579
+ server2.registerTool("gog_docs_comments_resolve", {
30580
+ description: "Resolve a comment (mark as done). Optionally include a closing message.",
30581
+ annotations: { destructiveHint: true },
30582
+ inputSchema: {
30583
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30584
+ commentId: external_exports3.string().describe("Comment ID to resolve"),
30585
+ message: external_exports3.string().optional().describe("Optional message to include when resolving"),
30586
+ account: accountParam
30587
+ }
30588
+ }, async ({ docId, commentId, message, account }) => {
30589
+ const args = ["docs", "comments", "resolve", docId, commentId];
30590
+ if (message) args.push(`--message=${message}`);
30591
+ return runOrDiagnose(args, { account });
30592
+ });
30593
+ server2.registerTool("gog_docs_comments_delete", {
30594
+ description: "Delete a comment from a Google Doc. This action is permanent.",
30595
+ annotations: { destructiveHint: true },
30596
+ inputSchema: {
30597
+ docId: external_exports3.string().describe("Doc ID (from the URL)"),
30598
+ commentId: external_exports3.string().describe("Comment ID to delete"),
30599
+ account: accountParam
30600
+ }
30601
+ }, async ({ docId, commentId, account }) => {
30602
+ return runOrDiagnose(["docs", "comments", "delete", docId, commentId], { account });
30603
+ });
30528
30604
  server2.registerTool("gog_docs_run", {
30529
30605
  description: "Run any gog docs subcommand not covered by the other tools. Run `gog docs --help` for the full list of subcommands, or `gog docs <subcommand> --help` for flags on a specific subcommand.",
30530
30606
  annotations: { destructiveHint: true },
@@ -30890,7 +30966,7 @@ function registerTasksTools(server2) {
30890
30966
  }
30891
30967
 
30892
30968
  // src/index.ts
30893
- var server = new McpServer({ name: "gogcli", version: "1.0.3" });
30969
+ var server = new McpServer({ name: "gogcli", version: "1.0.5" });
30894
30970
  registerAuthTools(server);
30895
30971
  registerCalendarTools(server);
30896
30972
  registerContactsTools(server);
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": "1.0.3",
6
+ "version": "1.0.5",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -37,8 +37,7 @@
37
37
  ],
38
38
  "env": {
39
39
  "GOG_ACCOUNT": "${user_config.gog_account}",
40
- "GOG_PATH": "${user_config.gog_path}",
41
- "GOG_ACCESS_TOKEN": "${user_config.gog_access_token}"
40
+ "GOG_PATH": "${user_config.gog_path}"
42
41
  }
43
42
  }
44
43
  },
@@ -54,13 +53,6 @@
54
53
  "title": "gog Executable Path",
55
54
  "description": "Path to the gog executable (optional — defaults to 'gog' on your PATH)",
56
55
  "required": false
57
- },
58
- "gog_access_token": {
59
- "type": "string",
60
- "title": "Google Access Token",
61
- "description": "Short-lived OAuth access token (optional — bypasses stored refresh tokens, expires in ~1h). Useful for CI or automated contexts.",
62
- "required": false,
63
- "sensitive": true
64
56
  }
65
57
  },
66
58
  "tools": [
@@ -236,6 +228,30 @@
236
228
  "name": "gog_docs_structure",
237
229
  "description": "Show document structure with numbered paragraphs"
238
230
  },
231
+ {
232
+ "name": "gog_docs_comments_list",
233
+ "description": "List comments on a Google Doc"
234
+ },
235
+ {
236
+ "name": "gog_docs_comments_get",
237
+ "description": "Get a single comment by ID with replies"
238
+ },
239
+ {
240
+ "name": "gog_docs_comments_add",
241
+ "description": "Add a comment to a Google Doc"
242
+ },
243
+ {
244
+ "name": "gog_docs_comments_reply",
245
+ "description": "Reply to a comment on a Google Doc"
246
+ },
247
+ {
248
+ "name": "gog_docs_comments_resolve",
249
+ "description": "Resolve (mark as done) a comment on a Google Doc"
250
+ },
251
+ {
252
+ "name": "gog_docs_comments_delete",
253
+ "description": "Delete a comment from a Google Doc"
254
+ },
239
255
  {
240
256
  "name": "gog_docs_run",
241
257
  "description": "Run any gog docs subcommand (escape hatch)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "MCP server wrapping gogcli for Google service access",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { registerGmailTools } from './tools/gmail.js';
10
10
  import { registerSheetsTools } from './tools/sheets.js';
11
11
  import { registerTasksTools } from './tools/tasks.js';
12
12
 
13
- const server = new McpServer({ name: 'gogcli', version: '1.0.3' });
13
+ const server = new McpServer({ name: 'gogcli', version: '1.0.5' });
14
14
 
15
15
  registerAuthTools(server);
16
16
  registerCalendarTools(server);
package/src/runner.ts CHANGED
@@ -42,9 +42,12 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
42
42
  const effectiveTimeout = timeout ?? TIMEOUT_MS;
43
43
 
44
44
  return new Promise((resolve, reject) => {
45
- const child = spawner(process.env.GOG_PATH ?? 'gog', fullArgs, { env: process.env });
46
- let stdout = '';
47
- let stderr = '';
45
+ // Strip GOG_ACCESS_TOKEN so gogcli uses stored refresh tokens instead of
46
+ // a potentially stale direct access token passed through MCP env config.
47
+ const { GOG_ACCESS_TOKEN: _, ...cleanEnv } = process.env;
48
+ const child = spawner(process.env.GOG_PATH ?? 'gog', fullArgs, { env: cleanEnv });
49
+ const stdoutChunks: Buffer[] = [];
50
+ const stderrChunks: Buffer[] = [];
48
51
  let settled = false;
49
52
 
50
53
  const timer = setTimeout(() => {
@@ -53,21 +56,23 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
53
56
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
54
57
  }, effectiveTimeout);
55
58
 
56
- child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
57
- child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
59
+ child.stdout!.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); });
60
+ child.stderr!.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); });
58
61
 
59
62
  child.on('close', (code: number | null) => {
60
63
  clearTimeout(timer);
61
64
  if (settled) return;
62
65
  settled = true;
66
+ const stdout = Buffer.concat(stdoutChunks).toString();
67
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
63
68
  if (code === 0) {
64
- if (interactive && stderr.trim()) {
69
+ if (interactive && stderr) {
65
70
  resolve(stdout + '\n' + stderr);
66
71
  } else {
67
72
  resolve(stdout);
68
73
  }
69
74
  } else {
70
- reject(new Error(stderr.trim() || `gog exited with code ${code}`));
75
+ reject(new Error(stderr || `gog exited with code ${code}`));
71
76
  }
72
77
  });
73
78
 
package/src/tools/auth.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { run } from '../runner.js';
4
- import { toText, toError } from './utils.js';
4
+ import { toText, toError, runOrDiagnose } from './utils.js';
5
5
 
6
6
  export function registerAuthTools(server: McpServer): void {
7
7
  server.registerTool('gog_auth_list', {
@@ -73,10 +73,6 @@ export function registerAuthTools(server: McpServer): void {
73
73
  args: z.array(z.string()).describe('Additional positional args and flags'),
74
74
  },
75
75
  }, async ({ subcommand, args }) => {
76
- try {
77
- return toText(await run(['auth', subcommand, ...args]));
78
- } catch (err) {
79
- return toError(err);
80
- }
76
+ return runOrDiagnose(['auth', subcommand, ...args], {});
81
77
  });
82
78
  }
package/src/tools/docs.ts CHANGED
@@ -27,7 +27,6 @@ export function registerDocsTools(server: McpServer): void {
27
27
 
28
28
  server.registerTool('gog_docs_create', {
29
29
  description: 'Create a new Google Doc. Returns JSON with the new docId and URL.',
30
- annotations: { destructiveHint: false },
31
30
  inputSchema: {
32
31
  title: z.string().describe('Title for the new document'),
33
32
  account: accountParam,
@@ -75,6 +74,89 @@ export function registerDocsTools(server: McpServer): void {
75
74
  return runOrDiagnose(['docs', 'structure', docId], { account });
76
75
  });
77
76
 
77
+ // --- Comments tools ---
78
+
79
+ server.registerTool('gog_docs_comments_list', {
80
+ description:
81
+ 'List comments on a Google Doc. Returns open comments by default; set includeResolved=true to include resolved comments.',
82
+ annotations: { readOnlyHint: true },
83
+ inputSchema: {
84
+ docId: z.string().describe('Doc ID (from the URL)'),
85
+ includeResolved: z.boolean().optional().describe('Include resolved comments (default: false, open only)'),
86
+ account: accountParam,
87
+ },
88
+ }, async ({ docId, includeResolved, account }) => {
89
+ const args = ['docs', 'comments', 'list', docId];
90
+ if (includeResolved) args.push('--include-resolved');
91
+ return runOrDiagnose(args, { account });
92
+ });
93
+
94
+ server.registerTool('gog_docs_comments_get', {
95
+ description: 'Get a single comment by ID, including its replies.',
96
+ annotations: { readOnlyHint: true },
97
+ inputSchema: {
98
+ docId: z.string().describe('Doc ID (from the URL)'),
99
+ commentId: z.string().describe('Comment ID'),
100
+ account: accountParam,
101
+ },
102
+ }, async ({ docId, commentId, account }) => {
103
+ return runOrDiagnose(['docs', 'comments', 'get', docId, commentId], { account });
104
+ });
105
+
106
+ server.registerTool('gog_docs_comments_add', {
107
+ description:
108
+ 'Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI.',
109
+ inputSchema: {
110
+ docId: z.string().describe('Doc ID (from the URL)'),
111
+ content: z.string().describe('Comment text'),
112
+ quoted: z.string().optional().describe('Quoted text to attach to the comment (shown in UIs when available)'),
113
+ account: accountParam,
114
+ },
115
+ }, async ({ docId, content, quoted, account }) => {
116
+ const args = ['docs', 'comments', 'add', docId, content];
117
+ if (quoted) args.push(`--quoted=${quoted}`);
118
+ return runOrDiagnose(args, { account });
119
+ });
120
+
121
+ server.registerTool('gog_docs_comments_reply', {
122
+ description: 'Reply to an existing comment on a Google Doc.',
123
+ inputSchema: {
124
+ docId: z.string().describe('Doc ID (from the URL)'),
125
+ commentId: z.string().describe('Comment ID to reply to'),
126
+ content: z.string().describe('Reply text'),
127
+ account: accountParam,
128
+ },
129
+ }, async ({ docId, commentId, content, account }) => {
130
+ return runOrDiagnose(['docs', 'comments', 'reply', docId, commentId, content], { account });
131
+ });
132
+
133
+ server.registerTool('gog_docs_comments_resolve', {
134
+ description: 'Resolve a comment (mark as done). Optionally include a closing message.',
135
+ annotations: { destructiveHint: true },
136
+ inputSchema: {
137
+ docId: z.string().describe('Doc ID (from the URL)'),
138
+ commentId: z.string().describe('Comment ID to resolve'),
139
+ message: z.string().optional().describe('Optional message to include when resolving'),
140
+ account: accountParam,
141
+ },
142
+ }, async ({ docId, commentId, message, account }) => {
143
+ const args = ['docs', 'comments', 'resolve', docId, commentId];
144
+ if (message) args.push(`--message=${message}`);
145
+ return runOrDiagnose(args, { account });
146
+ });
147
+
148
+ server.registerTool('gog_docs_comments_delete', {
149
+ description: 'Delete a comment from a Google Doc. This action is permanent.',
150
+ annotations: { destructiveHint: true },
151
+ inputSchema: {
152
+ docId: z.string().describe('Doc ID (from the URL)'),
153
+ commentId: z.string().describe('Comment ID to delete'),
154
+ account: accountParam,
155
+ },
156
+ }, async ({ docId, commentId, account }) => {
157
+ return runOrDiagnose(['docs', 'comments', 'delete', docId, commentId], { account });
158
+ });
159
+
78
160
  server.registerTool('gog_docs_run', {
79
161
  description: 'Run any gog docs subcommand not covered by the other tools. Run `gog docs --help` for the full list of subcommands, or `gog docs <subcommand> --help` for flags on a specific subcommand.',
80
162
  annotations: { destructiveHint: true },
@@ -15,8 +15,12 @@ export function toError(err: unknown): ToolResult {
15
15
  return toText(err instanceof Error ? `Error: ${err.message}` : String(err));
16
16
  }
17
17
 
18
- // On failure, appends `gog auth list` output so Claude can see which accounts
19
- // are configured and suggest the right one.
18
+ const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
19
+
20
+ const AUTH_HINT =
21
+ '\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. ' +
22
+ 'Ask the user if they would like to re-authenticate.';
23
+
20
24
  export async function runOrDiagnose(
21
25
  args: string[],
22
26
  options: { account?: string },
@@ -25,11 +29,14 @@ export async function runOrDiagnose(
25
29
  return toText(await run(args, options));
26
30
  } catch (err) {
27
31
  const base = toError(err);
32
+ const errText = base.content[0].text;
33
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
34
+ const hint = isAuthError ? AUTH_HINT : '';
28
35
  try {
29
36
  const accounts = await run(['auth', 'list']);
30
- return toText(`${base.content[0].text}\n\nConfigured accounts:\n${accounts}`);
37
+ return toText(`${errText}\n\nConfigured accounts:\n${accounts}${hint}`);
31
38
  } catch {
32
- return base;
39
+ return toText(`${errText}${hint}`);
33
40
  }
34
41
  }
35
42
  }
@@ -300,6 +300,23 @@ describe('run', () => {
300
300
  vi.useRealTimers();
301
301
  });
302
302
 
303
+ it('strips GOG_ACCESS_TOKEN from child environment to force refresh-token auth', async () => {
304
+ const spawner = makeSpawner(0, '{}');
305
+ const originalToken = process.env.GOG_ACCESS_TOKEN;
306
+ process.env.GOG_ACCESS_TOKEN = 'stale-token-from-mcp-config';
307
+ try {
308
+ await run(['docs', 'comments', 'list', 'docId'], { spawner });
309
+ const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
310
+ expect(envPassed.GOG_ACCESS_TOKEN).toBeUndefined();
311
+ } finally {
312
+ if (originalToken === undefined) {
313
+ delete process.env.GOG_ACCESS_TOKEN;
314
+ } else {
315
+ process.env.GOG_ACCESS_TOKEN = originalToken;
316
+ }
317
+ }
318
+ });
319
+
303
320
  it('ignores timeout if close event already settled the promise', async () => {
304
321
  vi.useFakeTimers();
305
322
  const spawner = vi.fn(() => {
@@ -121,7 +121,7 @@ describe('gog_auth_run', () => {
121
121
  vi.mocked(runner.run).mockResolvedValue('removed user@gmail.com');
122
122
  const handlers = setupHandlers();
123
123
  const result = await handlers.get('gog_auth_run')!({ subcommand: 'remove', args: ['user@gmail.com'] });
124
- expect(runner.run).toHaveBeenCalledWith(['auth', 'remove', 'user@gmail.com']);
124
+ expect(runner.run).toHaveBeenCalledWith(['auth', 'remove', 'user@gmail.com'], {});
125
125
  expect(result.content[0].text).toBe('removed user@gmail.com');
126
126
  });
127
127
 
@@ -129,7 +129,7 @@ describe('gog_auth_run', () => {
129
129
  vi.mocked(runner.run).mockResolvedValue('token info');
130
130
  const handlers = setupHandlers();
131
131
  await handlers.get('gog_auth_run')!({ subcommand: 'tokens', args: [] });
132
- expect(runner.run).toHaveBeenCalledWith(['auth', 'tokens']);
132
+ expect(runner.run).toHaveBeenCalledWith(['auth', 'tokens'], {});
133
133
  });
134
134
 
135
135
  it('returns error text on failure', async () => {
@@ -161,6 +161,193 @@ describe('gog_docs_structure', () => {
161
161
  });
162
162
  });
163
163
 
164
+ // --- Comments tools ---
165
+
166
+ describe('gog_docs_comments_list', () => {
167
+ it('calls run with correct args for open comments', async () => {
168
+ vi.mocked(runner.run).mockResolvedValue('[{"id":"c1","content":"Fix this"}]');
169
+ const handlers = setupHandlers();
170
+ const result = await handlers.get('gog_docs_comments_list')!({ docId: 'abc' });
171
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'comments', 'list', 'abc'], { account: undefined });
172
+ expect(result.content[0].text).toContain('Fix this');
173
+ });
174
+
175
+ it('includes --include-resolved when set', async () => {
176
+ vi.mocked(runner.run).mockResolvedValue('[]');
177
+ const handlers = setupHandlers();
178
+ await handlers.get('gog_docs_comments_list')!({ docId: 'abc', includeResolved: true });
179
+ expect(runner.run).toHaveBeenCalledWith(
180
+ ['docs', 'comments', 'list', 'abc', '--include-resolved'],
181
+ { account: undefined },
182
+ );
183
+ });
184
+
185
+ it('omits --include-resolved when false', async () => {
186
+ vi.mocked(runner.run).mockResolvedValue('[]');
187
+ const handlers = setupHandlers();
188
+ await handlers.get('gog_docs_comments_list')!({ docId: 'abc', includeResolved: false });
189
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'comments', 'list', 'abc'], { account: undefined });
190
+ });
191
+
192
+ it('forwards account override', async () => {
193
+ vi.mocked(runner.run).mockResolvedValue('[]');
194
+ const handlers = setupHandlers();
195
+ await handlers.get('gog_docs_comments_list')!({ docId: 'abc', account: 'other@gmail.com' });
196
+ expect(runner.run).toHaveBeenCalledWith(
197
+ ['docs', 'comments', 'list', 'abc'],
198
+ { account: 'other@gmail.com' },
199
+ );
200
+ });
201
+
202
+ it('returns error text on failure', async () => {
203
+ vi.mocked(runner.run).mockRejectedValue(new Error('List failed'));
204
+ const handlers = setupHandlers();
205
+ const result = await handlers.get('gog_docs_comments_list')!({ docId: 'bad' });
206
+ expect(result.content[0].text).toContain('Error: List failed');
207
+ });
208
+ });
209
+
210
+ describe('gog_docs_comments_get', () => {
211
+ it('calls run with correct args', async () => {
212
+ vi.mocked(runner.run).mockResolvedValue('{"id":"c1","content":"Fix this","replies":[]}');
213
+ const handlers = setupHandlers();
214
+ const result = await handlers.get('gog_docs_comments_get')!({ docId: 'abc', commentId: 'c1' });
215
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'comments', 'get', 'abc', 'c1'], { account: undefined });
216
+ expect(result.content[0].text).toContain('Fix this');
217
+ });
218
+
219
+ it('returns error text on failure', async () => {
220
+ vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
221
+ const handlers = setupHandlers();
222
+ const result = await handlers.get('gog_docs_comments_get')!({ docId: 'abc', commentId: 'bad' });
223
+ expect(result.content[0].text).toContain('Error: Not found');
224
+ });
225
+ });
226
+
227
+ describe('gog_docs_comments_add', () => {
228
+ it('calls run with content', async () => {
229
+ vi.mocked(runner.run).mockResolvedValue('{"id":"c2"}');
230
+ const handlers = setupHandlers();
231
+ await handlers.get('gog_docs_comments_add')!({ docId: 'abc', content: 'Please review' });
232
+ expect(runner.run).toHaveBeenCalledWith(
233
+ ['docs', 'comments', 'add', 'abc', 'Please review'],
234
+ { account: undefined },
235
+ );
236
+ });
237
+
238
+ it('includes --quoted when provided', async () => {
239
+ vi.mocked(runner.run).mockResolvedValue('{"id":"c2"}');
240
+ const handlers = setupHandlers();
241
+ await handlers.get('gog_docs_comments_add')!({
242
+ docId: 'abc',
243
+ content: 'Typo here',
244
+ quoted: 'teh',
245
+ });
246
+ expect(runner.run).toHaveBeenCalledWith(
247
+ ['docs', 'comments', 'add', 'abc', 'Typo here', '--quoted=teh'],
248
+ { account: undefined },
249
+ );
250
+ });
251
+
252
+ it('omits --quoted when not provided', async () => {
253
+ vi.mocked(runner.run).mockResolvedValue('{"id":"c2"}');
254
+ const handlers = setupHandlers();
255
+ await handlers.get('gog_docs_comments_add')!({ docId: 'abc', content: 'Nice' });
256
+ expect(runner.run).toHaveBeenCalledWith(
257
+ ['docs', 'comments', 'add', 'abc', 'Nice'],
258
+ { account: undefined },
259
+ );
260
+ });
261
+
262
+ it('returns error text on failure', async () => {
263
+ vi.mocked(runner.run).mockRejectedValue(new Error('Add failed'));
264
+ const handlers = setupHandlers();
265
+ const result = await handlers.get('gog_docs_comments_add')!({ docId: 'bad', content: 'x' });
266
+ expect(result.content[0].text).toContain('Error: Add failed');
267
+ });
268
+ });
269
+
270
+ describe('gog_docs_comments_reply', () => {
271
+ it('calls run with correct args', async () => {
272
+ vi.mocked(runner.run).mockResolvedValue('{"id":"r1"}');
273
+ const handlers = setupHandlers();
274
+ await handlers.get('gog_docs_comments_reply')!({ docId: 'abc', commentId: 'c1', content: 'Done' });
275
+ expect(runner.run).toHaveBeenCalledWith(
276
+ ['docs', 'comments', 'reply', 'abc', 'c1', 'Done'],
277
+ { account: undefined },
278
+ );
279
+ });
280
+
281
+ it('returns error text on failure', async () => {
282
+ vi.mocked(runner.run).mockRejectedValue(new Error('Reply failed'));
283
+ const handlers = setupHandlers();
284
+ const result = await handlers.get('gog_docs_comments_reply')!({
285
+ docId: 'abc', commentId: 'c1', content: 'x',
286
+ });
287
+ expect(result.content[0].text).toContain('Error: Reply failed');
288
+ });
289
+ });
290
+
291
+ describe('gog_docs_comments_resolve', () => {
292
+ it('calls run with correct args', async () => {
293
+ vi.mocked(runner.run).mockResolvedValue('{}');
294
+ const handlers = setupHandlers();
295
+ await handlers.get('gog_docs_comments_resolve')!({ docId: 'abc', commentId: 'c1' });
296
+ expect(runner.run).toHaveBeenCalledWith(
297
+ ['docs', 'comments', 'resolve', 'abc', 'c1'],
298
+ { account: undefined },
299
+ );
300
+ });
301
+
302
+ it('includes --message when provided', async () => {
303
+ vi.mocked(runner.run).mockResolvedValue('{}');
304
+ const handlers = setupHandlers();
305
+ await handlers.get('gog_docs_comments_resolve')!({
306
+ docId: 'abc', commentId: 'c1', message: 'Fixed in v2',
307
+ });
308
+ expect(runner.run).toHaveBeenCalledWith(
309
+ ['docs', 'comments', 'resolve', 'abc', 'c1', '--message=Fixed in v2'],
310
+ { account: undefined },
311
+ );
312
+ });
313
+
314
+ it('omits --message when not provided', async () => {
315
+ vi.mocked(runner.run).mockResolvedValue('{}');
316
+ const handlers = setupHandlers();
317
+ await handlers.get('gog_docs_comments_resolve')!({ docId: 'abc', commentId: 'c1' });
318
+ expect(runner.run).toHaveBeenCalledWith(
319
+ ['docs', 'comments', 'resolve', 'abc', 'c1'],
320
+ { account: undefined },
321
+ );
322
+ });
323
+
324
+ it('returns error text on failure', async () => {
325
+ vi.mocked(runner.run).mockRejectedValue(new Error('Resolve failed'));
326
+ const handlers = setupHandlers();
327
+ const result = await handlers.get('gog_docs_comments_resolve')!({ docId: 'abc', commentId: 'c1' });
328
+ expect(result.content[0].text).toContain('Error: Resolve failed');
329
+ });
330
+ });
331
+
332
+ describe('gog_docs_comments_delete', () => {
333
+ it('calls run with correct args', async () => {
334
+ vi.mocked(runner.run).mockResolvedValue('{}');
335
+ const handlers = setupHandlers();
336
+ await handlers.get('gog_docs_comments_delete')!({ docId: 'abc', commentId: 'c1' });
337
+ expect(runner.run).toHaveBeenCalledWith(
338
+ ['docs', 'comments', 'delete', 'abc', 'c1'],
339
+ { account: undefined },
340
+ );
341
+ });
342
+
343
+ it('returns error text on failure', async () => {
344
+ vi.mocked(runner.run).mockRejectedValue(new Error('Delete failed'));
345
+ const handlers = setupHandlers();
346
+ const result = await handlers.get('gog_docs_comments_delete')!({ docId: 'abc', commentId: 'c1' });
347
+ expect(result.content[0].text).toContain('Error: Delete failed');
348
+ });
349
+ });
350
+
164
351
  describe('gog_docs_run', () => {
165
352
  it('passes raw subcommand and args to runner', async () => {
166
353
  vi.mocked(runner.run).mockResolvedValue('{}');
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as runner from '../../src/runner.js';
3
+ import { runOrDiagnose } from '../../src/tools/utils.js';
4
+
5
+ vi.mock('../../src/runner.js');
6
+
7
+ beforeEach(() => vi.clearAllMocks());
8
+
9
+ describe('runOrDiagnose', () => {
10
+ it('returns output text on success', async () => {
11
+ vi.mocked(runner.run).mockResolvedValue('{"ok":true}');
12
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
13
+ expect(result.content[0].text).toBe('{"ok":true}');
14
+ });
15
+
16
+ it('appends auth list on non-auth failure', async () => {
17
+ vi.mocked(runner.run)
18
+ .mockRejectedValueOnce(new Error('Doc not found'))
19
+ .mockResolvedValueOnce('user@gmail.com');
20
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
21
+ expect(result.content[0].text).toBe(
22
+ 'Error: Doc not found\n\nConfigured accounts:\nuser@gmail.com',
23
+ );
24
+ expect(result.content[0].text).not.toContain('gog_auth_add');
25
+ });
26
+
27
+ it('appends re-auth hint on 401 error', async () => {
28
+ vi.mocked(runner.run)
29
+ .mockRejectedValueOnce(new Error('Request failed with status 401'))
30
+ .mockResolvedValueOnce('user@gmail.com');
31
+ const result = await runOrDiagnose(['docs', 'comments', 'list', 'abc'], {});
32
+ expect(result.content[0].text).toContain('Error: Request failed with status 401');
33
+ expect(result.content[0].text).toContain('Configured accounts:\nuser@gmail.com');
34
+ expect(result.content[0].text).toContain('gog_auth_add');
35
+ });
36
+
37
+ it('appends re-auth hint on "unauthorized" error', async () => {
38
+ vi.mocked(runner.run)
39
+ .mockRejectedValueOnce(new Error('unauthorized access'))
40
+ .mockResolvedValueOnce('user@gmail.com');
41
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
42
+ expect(result.content[0].text).toContain('gog_auth_add');
43
+ });
44
+
45
+ it('appends re-auth hint on "token expired" error', async () => {
46
+ vi.mocked(runner.run)
47
+ .mockRejectedValueOnce(new Error('token has been expired or revoked'))
48
+ .mockResolvedValueOnce('user@gmail.com');
49
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
50
+ expect(result.content[0].text).toContain('gog_auth_add');
51
+ });
52
+
53
+ it('appends re-auth hint on "invalid_grant" error', async () => {
54
+ vi.mocked(runner.run)
55
+ .mockRejectedValueOnce(new Error('invalid_grant'))
56
+ .mockResolvedValueOnce('user@gmail.com');
57
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
58
+ expect(result.content[0].text).toContain('gog_auth_add');
59
+ });
60
+
61
+ it('returns plain error with auth hint when auth list also fails on auth error', async () => {
62
+ vi.mocked(runner.run)
63
+ .mockRejectedValueOnce(new Error('Request failed with status 401'))
64
+ .mockRejectedValueOnce(new Error('auth list failed'));
65
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
66
+ expect(result.content[0].text).toContain('Error: Request failed with status 401');
67
+ expect(result.content[0].text).toContain('gog_auth_add');
68
+ expect(result.content[0].text).not.toContain('Configured accounts');
69
+ });
70
+
71
+ it('returns plain error when auth list also fails on non-auth error', async () => {
72
+ vi.mocked(runner.run)
73
+ .mockRejectedValueOnce(new Error('Doc not found'))
74
+ .mockRejectedValueOnce(new Error('auth list failed'));
75
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
76
+ expect(result.content[0].text).toBe('Error: Doc not found');
77
+ expect(result.content[0].text).not.toContain('gog_auth_add');
78
+ });
79
+ });