gogcli-mcp 2.0.11 → 2.2.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/dist/index.js +132 -34
- package/dist/lib.js +132 -34
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/tools/calendar.ts +12 -4
- package/src/tools/gmail.ts +1 -1
- package/src/tools/sheets-a1.ts +64 -0
- package/src/tools/sheets.ts +54 -14
- package/src/tools/utils.ts +59 -12
- package/tests/tools/calendar.test.ts +81 -0
- package/tests/tools/sheets-a1.test.ts +70 -0
- package/tests/tools/sheets.test.ts +162 -0
- package/tests/tools/utils.test.ts +103 -1
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.0
|
|
10
|
+
"version": "2.2.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.0
|
|
15
|
+
"version": "2.2.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/tools/calendar.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
server.registerTool('gog_calendar_create', {
|
|
42
|
-
description: 'Create a calendar event.',
|
|
42
|
+
description: 'Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description — Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients).',
|
|
43
43
|
annotations: { destructiveHint: false },
|
|
44
44
|
inputSchema: {
|
|
45
45
|
calendarId: z.string().describe('Calendar ID (use "primary" for the default calendar)'),
|
|
@@ -50,19 +50,21 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
50
50
|
location: z.string().optional().describe('Event location'),
|
|
51
51
|
attendees: z.string().optional().describe('Attendee emails, comma-separated'),
|
|
52
52
|
allDay: z.boolean().optional().describe('All-day event (use date-only in from/to)'),
|
|
53
|
+
withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)'),
|
|
53
54
|
account: accountParam,
|
|
54
55
|
},
|
|
55
|
-
}, async ({ calendarId, summary, from, to, description, location, attendees, allDay, account }) => {
|
|
56
|
+
}, async ({ calendarId, summary, from, to, description, location, attendees, allDay, withZoom, account }) => {
|
|
56
57
|
const args = ['calendar', 'create', calendarId, `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
|
|
57
58
|
if (description) args.push(`--description=${description}`);
|
|
58
59
|
if (location) args.push(`--location=${location}`);
|
|
59
60
|
if (attendees) args.push(`--attendees=${attendees}`);
|
|
60
61
|
if (allDay) args.push('--all-day');
|
|
62
|
+
if (withZoom) args.push('--with-zoom');
|
|
61
63
|
return runOrDiagnose(args, { account });
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
server.registerTool('gog_calendar_update', {
|
|
65
|
-
description: 'Update an existing calendar event.',
|
|
67
|
+
description: 'Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it (each are independent — use one per call).',
|
|
66
68
|
annotations: { destructiveHint: false },
|
|
67
69
|
inputSchema: {
|
|
68
70
|
calendarId: z.string().describe('Calendar ID'),
|
|
@@ -73,9 +75,12 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
73
75
|
description: z.string().optional().describe('New description'),
|
|
74
76
|
location: z.string().optional().describe('New location'),
|
|
75
77
|
attendees: z.string().optional().describe('New attendee emails, comma-separated (replaces existing)'),
|
|
78
|
+
withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event'),
|
|
79
|
+
regenerateZoom: z.boolean().optional().describe('Replace the event\'s existing Zoom video conference'),
|
|
80
|
+
removeZoom: z.boolean().optional().describe('Remove the event\'s Zoom video conference'),
|
|
76
81
|
account: accountParam,
|
|
77
82
|
},
|
|
78
|
-
}, async ({ calendarId, eventId, summary, from, to, description, location, attendees, account }) => {
|
|
83
|
+
}, async ({ calendarId, eventId, summary, from, to, description, location, attendees, withZoom, regenerateZoom, removeZoom, account }) => {
|
|
79
84
|
const args = ['calendar', 'update', calendarId, eventId];
|
|
80
85
|
if (summary !== undefined) args.push(`--summary=${summary}`);
|
|
81
86
|
if (from !== undefined) args.push(`--from=${from}`);
|
|
@@ -83,6 +88,9 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
83
88
|
if (description !== undefined) args.push(`--description=${description}`);
|
|
84
89
|
if (location !== undefined) args.push(`--location=${location}`);
|
|
85
90
|
if (attendees !== undefined) args.push(`--attendees=${attendees}`);
|
|
91
|
+
if (withZoom) args.push('--with-zoom');
|
|
92
|
+
if (regenerateZoom) args.push('--regenerate-zoom');
|
|
93
|
+
if (removeZoom) args.push('--remove-zoom');
|
|
86
94
|
return runOrDiagnose(args, { account });
|
|
87
95
|
});
|
|
88
96
|
|
package/src/tools/gmail.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
|
4
4
|
|
|
5
5
|
export function registerGmailTools(server: McpServer): void {
|
|
6
6
|
server.registerTool('gog_gmail_search', {
|
|
7
|
-
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread").',
|
|
7
|
+
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).',
|
|
8
8
|
annotations: { readOnlyHint: true },
|
|
9
9
|
inputSchema: {
|
|
10
10
|
query: z.string().describe('Gmail search query'),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A1-notation helpers for the fail_if_not_empty write guard. Kept separate
|
|
2
|
+
// from sheets.ts so the pure range/value math is unit-testable in isolation.
|
|
3
|
+
|
|
4
|
+
// 1 -> "A", 26 -> "Z", 27 -> "AA" (bijective base-26).
|
|
5
|
+
function colToLetter(n: number): string {
|
|
6
|
+
let s = '';
|
|
7
|
+
while (n > 0) {
|
|
8
|
+
const rem = (n - 1) % 26;
|
|
9
|
+
s = String.fromCharCode(65 + rem) + s;
|
|
10
|
+
n = Math.floor((n - 1) / 26);
|
|
11
|
+
}
|
|
12
|
+
return s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "A" -> 1, "AA" -> 27.
|
|
16
|
+
function letterToCol(s: string): number {
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (const ch of s.toUpperCase()) {
|
|
19
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
20
|
+
}
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Given the target `range` of an update and the shape of the values being
|
|
25
|
+
// written, return the range the guard should read to cover every written cell.
|
|
26
|
+
//
|
|
27
|
+
// A bare anchor cell ("Sheet1!A1") is expanded to the full written area
|
|
28
|
+
// ("Sheet1!A1:D70"). An explicit range (contains ":") or a named range is
|
|
29
|
+
// returned unchanged — the caller asked us to check exactly that span.
|
|
30
|
+
export function expandAnchorRange(range: string, rows: number, cols: number): string {
|
|
31
|
+
const bang = range.lastIndexOf('!');
|
|
32
|
+
const sheet = bang >= 0 ? range.slice(0, bang + 1) : '';
|
|
33
|
+
const cell = bang >= 0 ? range.slice(bang + 1) : range;
|
|
34
|
+
const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
|
|
35
|
+
if (!m) return range;
|
|
36
|
+
const startCol = letterToCol(m[1]);
|
|
37
|
+
const startRow = parseInt(m[2], 10);
|
|
38
|
+
const endCol = colToLetter(startCol + cols - 1);
|
|
39
|
+
const endRow = startRow + rows - 1;
|
|
40
|
+
return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Count cells holding data in the JSON output of `gog sheets get`
|
|
44
|
+
// ({"values":[[...]]}). Empty strings and whitespace-only cells don't count;
|
|
45
|
+
// numbers (including 0) and other primitives do. Returns -1 when the output
|
|
46
|
+
// can't be parsed, so the caller can fail safe rather than assume "empty".
|
|
47
|
+
export function countNonEmptyCells(getOutput: string): number {
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(getOutput);
|
|
51
|
+
} catch {
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
const values = (parsed as { values?: unknown })?.values;
|
|
55
|
+
if (!Array.isArray(values)) return 0;
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const row of values) {
|
|
58
|
+
if (!Array.isArray(row)) continue;
|
|
59
|
+
for (const cell of row) {
|
|
60
|
+
if (cell !== null && String(cell).trim() !== '') count++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return count;
|
|
64
|
+
}
|
package/src/tools/sheets.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { run } from '../runner.js';
|
|
4
|
+
import { accountParam, runOrDiagnose, registerRunTool, toText, diagnose } from './utils.js';
|
|
5
|
+
import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
|
|
4
6
|
|
|
5
7
|
// Cell value type: matches what gog sheets --values-json accepts (passed
|
|
6
8
|
// straight to the Sheets API as userEnteredValue). Strings starting with
|
|
7
9
|
// "=" are treated as formulas by gog's default --input=USER_ENTERED.
|
|
8
10
|
const cellValueParam = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
9
11
|
|
|
12
|
+
// gog sheets update/append/clear all support -n/--dry-run ("Do not make
|
|
13
|
+
// changes; print intended actions and exit successfully"). Exposing it gives
|
|
14
|
+
// agents a no-op preview before committing a write to a live sheet.
|
|
15
|
+
const dryRunParam = z.boolean().optional().describe(
|
|
16
|
+
'Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing.',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const failIfNotEmptyParam = z.boolean().optional().describe(
|
|
20
|
+
'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.',
|
|
21
|
+
);
|
|
22
|
+
|
|
10
23
|
export function registerSheetsTools(server: McpServer): void {
|
|
11
24
|
server.registerTool('gog_sheets_get', {
|
|
12
25
|
description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
|
|
@@ -27,13 +40,37 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
27
40
|
spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
|
|
28
41
|
range: z.string().describe('Top-left cell or range in A1 notation, e.g. Sheet1!A1'),
|
|
29
42
|
values: z.array(z.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
|
|
43
|
+
dry_run: dryRunParam,
|
|
44
|
+
fail_if_not_empty: failIfNotEmptyParam,
|
|
30
45
|
account: accountParam,
|
|
31
46
|
},
|
|
32
|
-
}, async ({ spreadsheetId, range, values, account }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
}, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
|
|
48
|
+
const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
|
|
49
|
+
if (fail_if_not_empty && values.length > 0 && cols > 0) {
|
|
50
|
+
const readRange = expandAnchorRange(range, values.length, cols);
|
|
51
|
+
let existing: string;
|
|
52
|
+
try {
|
|
53
|
+
existing = await run(['sheets', 'get', spreadsheetId, readRange], { account });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Couldn't read the target — refuse to write rather than risk an
|
|
56
|
+
// overwrite, and diagnose the read failure (auth/transient/etc.) the
|
|
57
|
+
// same way runOrDiagnose would, since this path bypasses it.
|
|
58
|
+
return diagnose(err);
|
|
59
|
+
}
|
|
60
|
+
const occupied = countNonEmptyCells(existing);
|
|
61
|
+
if (occupied !== 0) {
|
|
62
|
+
const detail = occupied < 0
|
|
63
|
+
? 'could not be verified as empty'
|
|
64
|
+
: `already contains data in ${occupied} cell(s)`;
|
|
65
|
+
return toText(
|
|
66
|
+
`Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. ` +
|
|
67
|
+
'Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const args = ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
72
|
+
if (dry_run) args.push('--dry-run');
|
|
73
|
+
return runOrDiagnose(args, { account });
|
|
37
74
|
});
|
|
38
75
|
|
|
39
76
|
server.registerTool('gog_sheets_append', {
|
|
@@ -43,13 +80,13 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
43
80
|
spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
|
|
44
81
|
range: z.string().describe('Range indicating which sheet/columns to append to, e.g. Sheet1!A:C'),
|
|
45
82
|
values: z.array(z.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
|
|
83
|
+
dry_run: dryRunParam,
|
|
46
84
|
account: accountParam,
|
|
47
85
|
},
|
|
48
|
-
}, async ({ spreadsheetId, range, values, account }) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
86
|
+
}, async ({ spreadsheetId, range, values, account, dry_run }) => {
|
|
87
|
+
const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
88
|
+
if (dry_run) args.push('--dry-run');
|
|
89
|
+
return runOrDiagnose(args, { account });
|
|
53
90
|
});
|
|
54
91
|
|
|
55
92
|
server.registerTool('gog_sheets_clear', {
|
|
@@ -58,14 +95,17 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
58
95
|
inputSchema: {
|
|
59
96
|
spreadsheetId: z.string().describe('Spreadsheet ID'),
|
|
60
97
|
range: z.string().describe('Range in A1 notation to clear'),
|
|
98
|
+
dry_run: dryRunParam,
|
|
61
99
|
account: accountParam,
|
|
62
100
|
},
|
|
63
|
-
}, async ({ spreadsheetId, range, account }) => {
|
|
64
|
-
|
|
101
|
+
}, async ({ spreadsheetId, range, account, dry_run }) => {
|
|
102
|
+
const args = ['sheets', 'clear', spreadsheetId, range];
|
|
103
|
+
if (dry_run) args.push('--dry-run');
|
|
104
|
+
return runOrDiagnose(args, { account });
|
|
65
105
|
});
|
|
66
106
|
|
|
67
107
|
server.registerTool('gog_sheets_metadata', {
|
|
68
|
-
description: 'Get spreadsheet metadata: title,
|
|
108
|
+
description: 'Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet\'s current size before writing — a write outside the grid fails.',
|
|
69
109
|
annotations: { readOnlyHint: true },
|
|
70
110
|
inputSchema: {
|
|
71
111
|
spreadsheetId: z.string().describe('Spreadsheet ID'),
|
package/src/tools/utils.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { run } from '../runner.js';
|
|
|
5
5
|
export type ToolResult = { content: [{ type: 'text'; text: string }] };
|
|
6
6
|
|
|
7
7
|
export const accountParam = z.string().optional().describe(
|
|
8
|
-
'Google account email to use
|
|
8
|
+
'Google account email to use, e.g. you@gmail.com — must be the full address, not a bare username. ' +
|
|
9
|
+
'Overrides the GOG_ACCOUNT env var. Omit to use the single configured account.',
|
|
9
10
|
);
|
|
10
11
|
|
|
11
12
|
// Canonical ID descriptors. Use these instead of redefining the same
|
|
@@ -100,6 +101,11 @@ const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid
|
|
|
100
101
|
const TRANSIENT_ERROR_PATTERN =
|
|
101
102
|
/\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
|
|
102
103
|
|
|
104
|
+
// gogcli rejects writes whose range falls outside the sheet's current grid
|
|
105
|
+
// (e.g. writing to column AP on a 41-column sheet) with a "exceeds grid limits"
|
|
106
|
+
// error and no remediation. Point the caller at the tool that grows the grid.
|
|
107
|
+
const GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
|
|
108
|
+
|
|
103
109
|
const AUTH_HINT =
|
|
104
110
|
'\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. ' +
|
|
105
111
|
'Ask the user if they would like to re-authenticate.';
|
|
@@ -108,6 +114,57 @@ const TRANSIENT_HINT =
|
|
|
108
114
|
'\n\nThis error is often transient. Retry the same call before trying a different approach ' +
|
|
109
115
|
'(do not fall back to smaller writes or row-by-row operations).';
|
|
110
116
|
|
|
117
|
+
const GRID_LIMIT_HINT =
|
|
118
|
+
'\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
|
|
119
|
+
'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
|
|
120
|
+
|
|
121
|
+
// Reduce `gog auth list --json` output to just the configured email addresses.
|
|
122
|
+
// The raw JSON also carries OAuth scopes, the Google subject id, and creation
|
|
123
|
+
// timestamps — none of which belong in an error surfaced to the model, and
|
|
124
|
+
// which were previously echoed verbatim on every failure. Falls back to the
|
|
125
|
+
// trimmed raw text if the output isn't the expected JSON shape (e.g. a plain
|
|
126
|
+
// email string), so unexpected output still degrades gracefully.
|
|
127
|
+
export function formatAccountList(raw: string): string {
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(raw) as { accounts?: unknown };
|
|
130
|
+
if (Array.isArray(parsed?.accounts)) {
|
|
131
|
+
return parsed.accounts
|
|
132
|
+
.map((a) => (a as { email?: string })?.email)
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join('\n');
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// not JSON — fall through to the raw text
|
|
138
|
+
}
|
|
139
|
+
return raw.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Turn a thrown error into a diagnosed ToolResult: the error text, an
|
|
143
|
+
// actionable hint when the failure class is recognised (auth / transient /
|
|
144
|
+
// off-grid write), and the list of configured accounts. Callers that need to
|
|
145
|
+
// surface a failure without going through runOrDiagnose (e.g. a pre-write
|
|
146
|
+
// verification read that must abort) can reuse this so the error keeps the
|
|
147
|
+
// same diagnostic quality as everywhere else.
|
|
148
|
+
export async function diagnose(err: unknown): Promise<ToolResult> {
|
|
149
|
+
const errText = toError(err).content[0].text;
|
|
150
|
+
const isAuthError = AUTH_ERROR_PATTERN.test(errText);
|
|
151
|
+
const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
152
|
+
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
153
|
+
const hint = isAuthError
|
|
154
|
+
? AUTH_HINT
|
|
155
|
+
: isTransientError
|
|
156
|
+
? TRANSIENT_HINT
|
|
157
|
+
: isGridLimitError
|
|
158
|
+
? GRID_LIMIT_HINT
|
|
159
|
+
: '';
|
|
160
|
+
try {
|
|
161
|
+
const accounts = formatAccountList(await run(['auth', 'list']));
|
|
162
|
+
return toText(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
|
|
163
|
+
} catch {
|
|
164
|
+
return toText(`${errText}${hint}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
111
168
|
export async function runOrDiagnose(
|
|
112
169
|
args: string[],
|
|
113
170
|
options: { account?: string },
|
|
@@ -115,16 +172,6 @@ export async function runOrDiagnose(
|
|
|
115
172
|
try {
|
|
116
173
|
return toText(await run(args, options));
|
|
117
174
|
} catch (err) {
|
|
118
|
-
|
|
119
|
-
const errText = base.content[0].text;
|
|
120
|
-
const isAuthError = AUTH_ERROR_PATTERN.test(errText);
|
|
121
|
-
const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
122
|
-
const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : '';
|
|
123
|
-
try {
|
|
124
|
-
const accounts = await run(['auth', 'list']);
|
|
125
|
-
return toText(`${errText}\n\nConfigured accounts:\n${accounts}${hint}`);
|
|
126
|
-
} catch {
|
|
127
|
-
return toText(`${errText}${hint}`);
|
|
128
|
-
}
|
|
175
|
+
return diagnose(err);
|
|
129
176
|
}
|
|
130
177
|
}
|
|
@@ -97,6 +97,41 @@ describe('gog_calendar_create', () => {
|
|
|
97
97
|
);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
// gog 0.18.0: --with-zoom attaches a Zoom conference via description-mode
|
|
101
|
+
// integration (native conference card not supported for non-Workspace-Marketplace
|
|
102
|
+
// OAuth clients).
|
|
103
|
+
it('passes --with-zoom when withZoom is true', async () => {
|
|
104
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
105
|
+
const handlers = setupHandlers();
|
|
106
|
+
await handlers.get('gog_calendar_create')!({
|
|
107
|
+
calendarId: 'primary',
|
|
108
|
+
summary: 'Sync',
|
|
109
|
+
from: '2026-04-14T09:00:00Z',
|
|
110
|
+
to: '2026-04-14T09:30:00Z',
|
|
111
|
+
withZoom: true,
|
|
112
|
+
});
|
|
113
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
114
|
+
[
|
|
115
|
+
'calendar', 'create', 'primary',
|
|
116
|
+
'--summary=Sync', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
|
|
117
|
+
'--with-zoom',
|
|
118
|
+
],
|
|
119
|
+
{ account: undefined },
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('omits --with-zoom when false', async () => {
|
|
124
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
125
|
+
const handlers = setupHandlers();
|
|
126
|
+
await handlers.get('gog_calendar_create')!({
|
|
127
|
+
calendarId: 'primary', summary: 's', from: 'f', to: 't', withZoom: false,
|
|
128
|
+
});
|
|
129
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
130
|
+
['calendar', 'create', 'primary', '--summary=s', '--from=f', '--to=t'],
|
|
131
|
+
{ account: undefined },
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
100
135
|
it('returns error text on failure', async () => {
|
|
101
136
|
vi.mocked(runner.run).mockRejectedValue(new Error('Create failed'));
|
|
102
137
|
const handlers = setupHandlers();
|
|
@@ -143,6 +178,52 @@ describe('gog_calendar_update', () => {
|
|
|
143
178
|
);
|
|
144
179
|
});
|
|
145
180
|
|
|
181
|
+
// gog 0.18.0 Zoom flags: with-zoom adds, regenerate-zoom replaces, remove-zoom strips.
|
|
182
|
+
it('passes --with-zoom / --regenerate-zoom / --remove-zoom independently', async () => {
|
|
183
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
184
|
+
const handlers = setupHandlers();
|
|
185
|
+
await handlers.get('gog_calendar_update')!({
|
|
186
|
+
calendarId: 'primary', eventId: 'evt1', withZoom: true,
|
|
187
|
+
});
|
|
188
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
189
|
+
['calendar', 'update', 'primary', 'evt1', '--with-zoom'],
|
|
190
|
+
{ account: undefined },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
vi.clearAllMocks();
|
|
194
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
195
|
+
await handlers.get('gog_calendar_update')!({
|
|
196
|
+
calendarId: 'primary', eventId: 'evt1', regenerateZoom: true,
|
|
197
|
+
});
|
|
198
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
199
|
+
['calendar', 'update', 'primary', 'evt1', '--regenerate-zoom'],
|
|
200
|
+
{ account: undefined },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
vi.clearAllMocks();
|
|
204
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
205
|
+
await handlers.get('gog_calendar_update')!({
|
|
206
|
+
calendarId: 'primary', eventId: 'evt1', removeZoom: true,
|
|
207
|
+
});
|
|
208
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
209
|
+
['calendar', 'update', 'primary', 'evt1', '--remove-zoom'],
|
|
210
|
+
{ account: undefined },
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('omits zoom flags when all false', async () => {
|
|
215
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
216
|
+
const handlers = setupHandlers();
|
|
217
|
+
await handlers.get('gog_calendar_update')!({
|
|
218
|
+
calendarId: 'primary', eventId: 'evt1',
|
|
219
|
+
withZoom: false, regenerateZoom: false, removeZoom: false,
|
|
220
|
+
});
|
|
221
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
222
|
+
['calendar', 'update', 'primary', 'evt1'],
|
|
223
|
+
{ account: undefined },
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
146
227
|
it('returns error text on failure', async () => {
|
|
147
228
|
vi.mocked(runner.run).mockRejectedValue(new Error('Update failed'));
|
|
148
229
|
const handlers = setupHandlers();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { expandAnchorRange, countNonEmptyCells } from '../../src/tools/sheets-a1.js';
|
|
3
|
+
|
|
4
|
+
describe('expandAnchorRange', () => {
|
|
5
|
+
it('expands a bare anchor cell to the full written area', () => {
|
|
6
|
+
expect(expandAnchorRange('A1', 3, 2)).toBe('A1:B3');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('preserves a sheet-name prefix when expanding', () => {
|
|
10
|
+
expect(expandAnchorRange('Sheet1!A1', 70, 4)).toBe('Sheet1!A1:D70');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('generates multi-letter end columns past Z', () => {
|
|
14
|
+
expect(expandAnchorRange('A1', 1, 27)).toBe('A1:AA1');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('parses multi-letter start columns', () => {
|
|
18
|
+
expect(expandAnchorRange('AA5', 1, 1)).toBe('AA5:AA5');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('uppercases a lowercase anchor column', () => {
|
|
22
|
+
expect(expandAnchorRange('b2', 1, 1)).toBe('B2:B2');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('leaves an explicit A1 range (with a colon) unchanged', () => {
|
|
26
|
+
expect(expandAnchorRange('Sheet1!A1:D70', 5, 5)).toBe('Sheet1!A1:D70');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('leaves a named range unchanged', () => {
|
|
30
|
+
expect(expandAnchorRange('MyNamedRange', 5, 5)).toBe('MyNamedRange');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('countNonEmptyCells', () => {
|
|
35
|
+
it('counts cells that hold data across ragged rows', () => {
|
|
36
|
+
expect(countNonEmptyCells('{"values":[["a","b"],["c"]]}')).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('treats empty strings as empty', () => {
|
|
40
|
+
expect(countNonEmptyCells('{"values":[["","",""]]}')).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('treats whitespace-only cells as empty', () => {
|
|
44
|
+
expect(countNonEmptyCells('{"values":[[" "]]}')).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('counts numbers and zero as data, skips nulls', () => {
|
|
48
|
+
expect(countNonEmptyCells('{"values":[[1,0,null,"x"]]}')).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns 0 when there is no values key', () => {
|
|
52
|
+
expect(countNonEmptyCells('{}')).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns 0 when values is not an array', () => {
|
|
56
|
+
expect(countNonEmptyCells('{"values":"foo"}')).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns 0 when the parsed JSON is null', () => {
|
|
60
|
+
expect(countNonEmptyCells('null')).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('skips non-array rows', () => {
|
|
64
|
+
expect(countNonEmptyCells('{"values":[null,["a"]]}')).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns -1 when the output is not valid JSON', () => {
|
|
68
|
+
expect(countNonEmptyCells('not json')).toBe(-1);
|
|
69
|
+
});
|
|
70
|
+
});
|