gogcli-mcp-sheets 2.5.0 → 2.6.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/SKILL.md +2 -0
- package/dist/index.js +75 -5
- package/manifest.json +10 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/tools/sheets-extra.ts +107 -9
- package/tests/tools/sheets-extra.test.ts +146 -8
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.6.1"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli (Sheets)",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.6.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ Plus 5 auth tools and 8 base Sheets tools (get, update, append, clear, metadata,
|
|
|
72
72
|
| `gog_sheets_notes` | Read cell notes |
|
|
73
73
|
| `gog_sheets_update_note` | Add or update cell notes |
|
|
74
74
|
| `gog_sheets_links` | List hyperlinks in a range |
|
|
75
|
+
| `gog_sheets_set_links` | Set =HYPERLINK() cells in one call (batch) |
|
|
76
|
+
| `gog_sheets_snapshot` | Back up a whole spreadsheet before a risky edit |
|
|
75
77
|
|
|
76
78
|
## License
|
|
77
79
|
|
package/SKILL.md
CHANGED
|
@@ -51,6 +51,8 @@ Extended Google Sheets MCP server via [gogcli](https://github.com/openclaw/gogcl
|
|
|
51
51
|
| `gog_sheets_notes` | Read cell notes |
|
|
52
52
|
| `gog_sheets_update_note` | Add or update cell notes |
|
|
53
53
|
| `gog_sheets_links` | List hyperlinks in a range |
|
|
54
|
+
| `gog_sheets_set_links` | Set =HYPERLINK() cells in one call (batch) |
|
|
55
|
+
| `gog_sheets_snapshot` | Back up a whole spreadsheet before a risky edit |
|
|
54
56
|
| `gog_sheets_named_ranges_list` | List named ranges |
|
|
55
57
|
| `gog_sheets_named_ranges_get` | Get a named range |
|
|
56
58
|
| `gog_sheets_named_ranges_add` | Create a named range |
|
package/dist/index.js
CHANGED
|
@@ -31399,7 +31399,7 @@ function registerSheetsTools(server2) {
|
|
|
31399
31399
|
}
|
|
31400
31400
|
|
|
31401
31401
|
// ../gogcli-mcp/src/server.ts
|
|
31402
|
-
var VERSION = true ? "2.
|
|
31402
|
+
var VERSION = true ? "2.6.1" : "0.0.0";
|
|
31403
31403
|
function createServer(options) {
|
|
31404
31404
|
return new McpServer({
|
|
31405
31405
|
name: options?.name ?? "gogcli",
|
|
@@ -31552,7 +31552,7 @@ function registerExtraSheetsTools(server2) {
|
|
|
31552
31552
|
account: accountParam
|
|
31553
31553
|
}
|
|
31554
31554
|
}, async ({ spreadsheetId, sheet, dimension, start, count, after, inheritFromBefore, account }) => {
|
|
31555
|
-
const effectiveStart =
|
|
31555
|
+
const effectiveStart = start + 1;
|
|
31556
31556
|
const args = ["sheets", "insert", spreadsheetId, sheet, dimension, String(effectiveStart)];
|
|
31557
31557
|
if (count !== void 0) args.push(`--count=${count}`);
|
|
31558
31558
|
if (after) args.push("--after");
|
|
@@ -31794,6 +31794,27 @@ ${result.content[0].text}` }] };
|
|
|
31794
31794
|
}, async ({ spreadsheetId, range, account }) => {
|
|
31795
31795
|
return runOrDiagnose(["sheets", "links", spreadsheetId, range], { account });
|
|
31796
31796
|
});
|
|
31797
|
+
server2.registerTool("gog_sheets_links_set", {
|
|
31798
|
+
description: "Set cell hyperlinks in a Google Sheet. Three modes: (1) single link \u2014 pass cell + url (+ optional text); (2) multi-link cell \u2014 pass cell + runsJson, a JSON array of rich-text runs (a run with an empty uri is plain text); (3) batch \u2014 pass cellsJson, a JSON array of {cell,url,text} or {cell,runs:[...]} objects written in one request.",
|
|
31799
|
+
annotations: { destructiveHint: true },
|
|
31800
|
+
inputSchema: {
|
|
31801
|
+
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
31802
|
+
cell: external_exports.string().optional().describe("Target cell in A1 notation (e.g. Sheet1!B2). Used by single-link and runsJson modes; omit for batch (cellsJson)."),
|
|
31803
|
+
url: external_exports.string().optional().describe("Hyperlink URL for single-link mode"),
|
|
31804
|
+
text: external_exports.string().optional().describe("Display text for single-link mode (defaults to the URL when omitted)"),
|
|
31805
|
+
runsJson: external_exports.string().optional().describe('Multi-link cell: JSON array of runs, e.g. [{"text":"Act A","uri":"https://a"},{"text":" / "},{"text":"Act B","uri":"https://b"}]. A run with an empty uri is plain text.'),
|
|
31806
|
+
cellsJson: external_exports.string().optional().describe("Batch: JSON array of {cell,url,text} or {cell,runs:[{text,uri}]} objects, written in one request."),
|
|
31807
|
+
account: accountParam
|
|
31808
|
+
}
|
|
31809
|
+
}, async ({ spreadsheetId, cell, url: url2, text, runsJson, cellsJson, account }) => {
|
|
31810
|
+
const args = ["sheets", "links", "set", spreadsheetId];
|
|
31811
|
+
if (cell) args.push(cell);
|
|
31812
|
+
if (url2) args.push(url2);
|
|
31813
|
+
if (text) args.push(text);
|
|
31814
|
+
if (runsJson) args.push(`--runs-json=${runsJson}`);
|
|
31815
|
+
if (cellsJson) args.push(`--cells-json=${cellsJson}`);
|
|
31816
|
+
return runOrDiagnose(args, { account });
|
|
31817
|
+
});
|
|
31797
31818
|
server2.registerTool("gog_sheets_named_ranges_list", {
|
|
31798
31819
|
description: "List all named ranges in a spreadsheet.",
|
|
31799
31820
|
annotations: { readOnlyHint: true },
|
|
@@ -32016,15 +32037,51 @@ ${result.content[0].text}` }] };
|
|
|
32016
32037
|
return runOrDiagnose(["sheets", "table", "clear", spreadsheetId, tableId], { account });
|
|
32017
32038
|
});
|
|
32018
32039
|
server2.registerTool("gog_sheets_table_delete", {
|
|
32019
|
-
description: "Delete a table by
|
|
32040
|
+
description: "Delete a Google Sheets table. WARNING: the underlying `gog`/Sheets behaviour is that deleting a table also DESTROYS every cell value in the table range \u2014 not just the table styling/columns. By default this tool prevents that data loss by emulating the Sheets UI's \"Convert to range\": it reads the table's cells (values AND formulas) first, deletes the table, then restores the data into the now-plain range. Formatting, banding, and data-validation dropdowns are still lost (gog cannot round-trip those yet). Set keep_data=false to delete the table AND wipe its cell data (the raw destructive behaviour). To preserve everything, snapshot the whole spreadsheet first with gog_sheets_snapshot.",
|
|
32020
32041
|
annotations: { destructiveHint: true },
|
|
32021
32042
|
inputSchema: {
|
|
32022
32043
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
32023
32044
|
tableId: external_exports.string().describe("Table ID to delete"),
|
|
32045
|
+
keep_data: external_exports.boolean().optional().describe(
|
|
32046
|
+
`Default true: preserve the table's cell values and formulas (read \u2192 delete \u2192 restore), emulating "Convert to range". Set false to delete the table AND its data.`
|
|
32047
|
+
),
|
|
32024
32048
|
account: accountParam
|
|
32025
32049
|
}
|
|
32026
|
-
}, async ({ spreadsheetId, tableId, account }) => {
|
|
32027
|
-
|
|
32050
|
+
}, async ({ spreadsheetId, tableId, keep_data = true, account }) => {
|
|
32051
|
+
if (!keep_data) {
|
|
32052
|
+
return runOrDiagnose(["sheets", "table", "delete", spreadsheetId, tableId, "--discard-data", "--force"], { account });
|
|
32053
|
+
}
|
|
32054
|
+
let range;
|
|
32055
|
+
let values;
|
|
32056
|
+
try {
|
|
32057
|
+
const parsed = JSON.parse(await run(["sheets", "table", "get", spreadsheetId, tableId], { account }));
|
|
32058
|
+
range = parsed.table?.a1;
|
|
32059
|
+
if (!range) throw new Error(`could not determine the table's range (no "a1" in table get output) for table ${tableId}`);
|
|
32060
|
+
const read = JSON.parse(await run(["sheets", "get", spreadsheetId, range, "--render=FORMULA"], { account }));
|
|
32061
|
+
values = read.values ?? [];
|
|
32062
|
+
} catch (err) {
|
|
32063
|
+
return diagnose(err);
|
|
32064
|
+
}
|
|
32065
|
+
try {
|
|
32066
|
+
await run(["sheets", "table", "delete", spreadsheetId, tableId, "--discard-data", "--force"], { account });
|
|
32067
|
+
} catch (err) {
|
|
32068
|
+
return diagnose(err);
|
|
32069
|
+
}
|
|
32070
|
+
if (values.length > 0) {
|
|
32071
|
+
try {
|
|
32072
|
+
await run(["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`], { account });
|
|
32073
|
+
} catch (err) {
|
|
32074
|
+
return toText(
|
|
32075
|
+
`Table ${tableId} was deleted, but restoring its data FAILED \u2014 re-apply the values below manually.
|
|
32076
|
+
|
|
32077
|
+
Range: ${range}
|
|
32078
|
+
Values: ${JSON.stringify(values)}
|
|
32079
|
+
|
|
32080
|
+
${toError(err).content[0].text}`
|
|
32081
|
+
);
|
|
32082
|
+
}
|
|
32083
|
+
}
|
|
32084
|
+
return toText(`Deleted table ${tableId} and preserved ${values.length} row(s) of data (values + formulas) in ${range}.`);
|
|
32028
32085
|
});
|
|
32029
32086
|
server2.registerTool("gog_sheets_banding_list", {
|
|
32030
32087
|
description: "List alternating-color banded ranges. Optionally scope to a single sheet.",
|
|
@@ -32130,6 +32187,19 @@ ${result.content[0].text}` }] };
|
|
|
32130
32187
|
if (all) args.push("--all");
|
|
32131
32188
|
return runOrDiagnose(args, { account });
|
|
32132
32189
|
});
|
|
32190
|
+
server2.registerTool("gog_sheets_snapshot", {
|
|
32191
|
+
description: "Make a backup copy of an entire spreadsheet \u2014 a one-call safety snapshot to take BEFORE a risky or destructive edit (table delete, bulk clear, large rewrite). Returns the new copy's file ID and URL; if the edit goes wrong, restore by copying the backup back or sharing it. The copy is independent \u2014 later edits to the original do not affect it.",
|
|
32192
|
+
inputSchema: {
|
|
32193
|
+
spreadsheetId: external_exports.string().describe("Spreadsheet ID to back up"),
|
|
32194
|
+
name: external_exports.string().describe('Name for the backup copy, e.g. "Budget \u2014 backup before table delete"'),
|
|
32195
|
+
parent: external_exports.string().optional().describe("Destination folder ID for the copy (default: same location as the original)"),
|
|
32196
|
+
account: accountParam
|
|
32197
|
+
}
|
|
32198
|
+
}, async ({ spreadsheetId, name, parent, account }) => {
|
|
32199
|
+
const args = ["drive", "copy", spreadsheetId, name];
|
|
32200
|
+
if (parent) args.push(`--parent=${parent}`);
|
|
32201
|
+
return runOrDiagnose(args, { account });
|
|
32202
|
+
});
|
|
32133
32203
|
}
|
|
32134
32204
|
|
|
32135
32205
|
// src/index.ts
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-sheets",
|
|
5
5
|
"display_name": "gogcli (Sheets)",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.6.1",
|
|
7
7
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -180,6 +180,10 @@
|
|
|
180
180
|
"name": "gog_sheets_links",
|
|
181
181
|
"description": "List hyperlinks in a range."
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
"name": "gog_sheets_links_set",
|
|
185
|
+
"description": "Set native cell hyperlinks: single link, multi-link rich-text cell (runsJson), or batch (cellsJson)."
|
|
186
|
+
},
|
|
183
187
|
{
|
|
184
188
|
"name": "gog_sheets_named_ranges_list",
|
|
185
189
|
"description": "List all named ranges in a spreadsheet."
|
|
@@ -254,7 +258,11 @@
|
|
|
254
258
|
},
|
|
255
259
|
{
|
|
256
260
|
"name": "gog_sheets_table_delete",
|
|
257
|
-
"description": "Delete a table
|
|
261
|
+
"description": "Delete a Google Sheets table. By default preserves the table's cell values and formulas (emulates \"Convert to range\"); set keep_data=false to also wipe the data."
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"name": "gog_sheets_snapshot",
|
|
265
|
+
"description": "Make a backup copy of an entire spreadsheet before a risky/destructive edit. Returns the copy's file ID and URL."
|
|
258
266
|
},
|
|
259
267
|
{
|
|
260
268
|
"name": "gog_sheets_banding_list",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-sheets",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-sheets",
|
|
5
5
|
"description": "Extended Google Sheets MCP server via gogcli — all base tools plus full Sheets support",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-sheets"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.6.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-sheets",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.6.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { accountParam, runOrDiagnose } from '../../../gogcli-mcp/src/lib.js';
|
|
3
|
+
import { accountParam, runOrDiagnose, run, diagnose, toText, toError } from '../../../gogcli-mcp/src/lib.js';
|
|
4
4
|
|
|
5
5
|
// Convert a CSS-style hex color ("#FFF5D9", "#FD9", "FFF5D9") to the
|
|
6
6
|
// {red, green, blue} 0-1 float triple that Sheets API CellFormat expects.
|
|
@@ -182,11 +182,12 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
182
182
|
account: accountParam,
|
|
183
183
|
},
|
|
184
184
|
}, async ({ spreadsheetId, sheet, dimension, start, count, after, inheritFromBefore, account }) => {
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
185
|
+
// gog's positional `<start>` is 1-based, and it rejects 0 ("start must be >= 1").
|
|
186
|
+
// Without --after it inserts *before* the position, so API start_index = positional - 1;
|
|
187
|
+
// with --after it inserts *after*, so API start_index = positional. Our `start` is 0-based,
|
|
188
|
+
// and the contract is: after:false lands at start_index=start, after:true at start_index=start+1.
|
|
189
|
+
// Both cases reduce to sending positional = start + 1 (issue #42 + the off-by-one this fixes).
|
|
190
|
+
const effectiveStart = start + 1;
|
|
190
191
|
const args = ['sheets', 'insert', spreadsheetId, sheet, dimension, String(effectiveStart)];
|
|
191
192
|
if (count !== undefined) args.push(`--count=${count}`);
|
|
192
193
|
if (after) args.push('--after');
|
|
@@ -440,6 +441,31 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
440
441
|
return runOrDiagnose(['sheets', 'links', spreadsheetId, range], { account });
|
|
441
442
|
});
|
|
442
443
|
|
|
444
|
+
server.registerTool('gog_sheets_links_set', {
|
|
445
|
+
description:
|
|
446
|
+
'Set cell hyperlinks in a Google Sheet. Three modes: (1) single link — pass cell + url (+ optional text); ' +
|
|
447
|
+
'(2) multi-link cell — pass cell + runsJson, a JSON array of rich-text runs (a run with an empty uri is plain text); ' +
|
|
448
|
+
'(3) batch — pass cellsJson, a JSON array of {cell,url,text} or {cell,runs:[...]} objects written in one request.',
|
|
449
|
+
annotations: { destructiveHint: true },
|
|
450
|
+
inputSchema: {
|
|
451
|
+
spreadsheetId: z.string().describe('Spreadsheet ID'),
|
|
452
|
+
cell: z.string().optional().describe('Target cell in A1 notation (e.g. Sheet1!B2). Used by single-link and runsJson modes; omit for batch (cellsJson).'),
|
|
453
|
+
url: z.string().optional().describe('Hyperlink URL for single-link mode'),
|
|
454
|
+
text: z.string().optional().describe('Display text for single-link mode (defaults to the URL when omitted)'),
|
|
455
|
+
runsJson: z.string().optional().describe('Multi-link cell: JSON array of runs, e.g. [{"text":"Act A","uri":"https://a"},{"text":" / "},{"text":"Act B","uri":"https://b"}]. A run with an empty uri is plain text.'),
|
|
456
|
+
cellsJson: z.string().optional().describe('Batch: JSON array of {cell,url,text} or {cell,runs:[{text,uri}]} objects, written in one request.'),
|
|
457
|
+
account: accountParam,
|
|
458
|
+
},
|
|
459
|
+
}, async ({ spreadsheetId, cell, url, text, runsJson, cellsJson, account }) => {
|
|
460
|
+
const args = ['sheets', 'links', 'set', spreadsheetId];
|
|
461
|
+
if (cell) args.push(cell);
|
|
462
|
+
if (url) args.push(url);
|
|
463
|
+
if (text) args.push(text);
|
|
464
|
+
if (runsJson) args.push(`--runs-json=${runsJson}`);
|
|
465
|
+
if (cellsJson) args.push(`--cells-json=${cellsJson}`);
|
|
466
|
+
return runOrDiagnose(args, { account });
|
|
467
|
+
});
|
|
468
|
+
|
|
443
469
|
server.registerTool('gog_sheets_named_ranges_list', {
|
|
444
470
|
description: 'List all named ranges in a spreadsheet.',
|
|
445
471
|
annotations: { readOnlyHint: true },
|
|
@@ -683,15 +709,70 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
683
709
|
});
|
|
684
710
|
|
|
685
711
|
server.registerTool('gog_sheets_table_delete', {
|
|
686
|
-
description:
|
|
712
|
+
description:
|
|
713
|
+
'Delete a Google Sheets table. WARNING: the underlying `gog`/Sheets behaviour is that deleting a table also DESTROYS every cell value in the table range — not just the table styling/columns. ' +
|
|
714
|
+
'By default this tool prevents that data loss by emulating the Sheets UI\'s "Convert to range": it reads the table\'s cells (values AND formulas) first, deletes the table, then restores the data into the now-plain range. ' +
|
|
715
|
+
'Formatting, banding, and data-validation dropdowns are still lost (gog cannot round-trip those yet). ' +
|
|
716
|
+
'Set keep_data=false to delete the table AND wipe its cell data (the raw destructive behaviour). To preserve everything, snapshot the whole spreadsheet first with gog_sheets_snapshot.',
|
|
687
717
|
annotations: { destructiveHint: true },
|
|
688
718
|
inputSchema: {
|
|
689
719
|
spreadsheetId: z.string().describe('Spreadsheet ID'),
|
|
690
720
|
tableId: z.string().describe('Table ID to delete'),
|
|
721
|
+
keep_data: z.boolean().optional().describe(
|
|
722
|
+
'Default true: preserve the table\'s cell values and formulas (read → delete → restore), emulating "Convert to range". Set false to delete the table AND its data.',
|
|
723
|
+
),
|
|
691
724
|
account: accountParam,
|
|
692
725
|
},
|
|
693
|
-
}, async ({ spreadsheetId, tableId, account }) => {
|
|
694
|
-
|
|
726
|
+
}, async ({ spreadsheetId, tableId, keep_data = true, account }) => {
|
|
727
|
+
if (!keep_data) {
|
|
728
|
+
// Raw destructive delete: removes the table and its cell data. gog ≥ 0.23
|
|
729
|
+
// requires --discard-data to confirm the cell wipe (--force only skips the
|
|
730
|
+
// interactive confirmation; it does not authorize the data loss).
|
|
731
|
+
return runOrDiagnose(['sheets', 'table', 'delete', spreadsheetId, tableId, '--discard-data', '--force'], { account });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// keep_data: emulate "Convert to range". Read the full table range with
|
|
735
|
+
// formulas intact, then delete, then write the data back. If we can't read
|
|
736
|
+
// the data first, refuse to delete — losing the backup is the whole risk.
|
|
737
|
+
let range: string;
|
|
738
|
+
let values: unknown[][];
|
|
739
|
+
try {
|
|
740
|
+
// `gog sheets table get` wraps the table under a top-level "table" key.
|
|
741
|
+
const parsed = JSON.parse(await run(['sheets', 'table', 'get', spreadsheetId, tableId], { account }));
|
|
742
|
+
range = parsed.table?.a1;
|
|
743
|
+
if (!range) throw new Error(`could not determine the table's range (no "a1" in table get output) for table ${tableId}`);
|
|
744
|
+
const read = JSON.parse(await run(['sheets', 'get', spreadsheetId, range, '--render=FORMULA'], { account }));
|
|
745
|
+
values = read.values ?? [];
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return diagnose(err);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
try {
|
|
751
|
+
// We have already backed up the cells above, so --discard-data (required
|
|
752
|
+
// by gog ≥ 0.23) is the intended path here — we re-write the data right
|
|
753
|
+
// after.
|
|
754
|
+
await run(['sheets', 'table', 'delete', spreadsheetId, tableId, '--discard-data', '--force'], { account });
|
|
755
|
+
} catch (err) {
|
|
756
|
+
// Table not deleted; cell data is untouched.
|
|
757
|
+
return diagnose(err);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (values.length > 0) {
|
|
761
|
+
try {
|
|
762
|
+
await run(['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`], { account });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
// The table is already gone but the restore write failed. Hand the
|
|
765
|
+
// read-back data straight back so it can be re-applied manually rather
|
|
766
|
+
// than silently lost.
|
|
767
|
+
return toText(
|
|
768
|
+
`Table ${tableId} was deleted, but restoring its data FAILED — re-apply the values below manually.\n\n` +
|
|
769
|
+
`Range: ${range}\nValues: ${JSON.stringify(values)}\n\n` +
|
|
770
|
+
`${toError(err).content[0].text}`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return toText(`Deleted table ${tableId} and preserved ${values.length} row(s) of data (values + formulas) in ${range}.`);
|
|
695
776
|
});
|
|
696
777
|
|
|
697
778
|
// ---- Banding / alternating colors (gog 0.19.0) ----
|
|
@@ -798,4 +879,21 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
798
879
|
if (all) args.push('--all');
|
|
799
880
|
return runOrDiagnose(args, { account });
|
|
800
881
|
});
|
|
882
|
+
|
|
883
|
+
server.registerTool('gog_sheets_snapshot', {
|
|
884
|
+
description:
|
|
885
|
+
'Make a backup copy of an entire spreadsheet — a one-call safety snapshot to take BEFORE a risky or destructive edit (table delete, bulk clear, large rewrite). ' +
|
|
886
|
+
'Returns the new copy\'s file ID and URL; if the edit goes wrong, restore by copying the backup back or sharing it. The copy is independent — later edits to the original do not affect it.',
|
|
887
|
+
inputSchema: {
|
|
888
|
+
spreadsheetId: z.string().describe('Spreadsheet ID to back up'),
|
|
889
|
+
name: z.string().describe('Name for the backup copy, e.g. "Budget — backup before table delete"'),
|
|
890
|
+
parent: z.string().optional().describe('Destination folder ID for the copy (default: same location as the original)'),
|
|
891
|
+
account: accountParam,
|
|
892
|
+
},
|
|
893
|
+
}, async ({ spreadsheetId, name, parent, account }) => {
|
|
894
|
+
const args = ['drive', 'copy', spreadsheetId, name];
|
|
895
|
+
if (parent) args.push(`--parent=${parent}`);
|
|
896
|
+
return runOrDiagnose(args, { account });
|
|
897
|
+
});
|
|
898
|
+
|
|
801
899
|
}
|
|
@@ -8,6 +8,8 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
8
8
|
return {
|
|
9
9
|
...actual,
|
|
10
10
|
runOrDiagnose: vi.fn(),
|
|
11
|
+
run: vi.fn(),
|
|
12
|
+
diagnose: vi.fn(),
|
|
11
13
|
};
|
|
12
14
|
});
|
|
13
15
|
|
|
@@ -112,11 +114,23 @@ describe('gog_sheets_freeze', () => {
|
|
|
112
114
|
|
|
113
115
|
// 7. insert
|
|
114
116
|
describe('gog_sheets_insert', () => {
|
|
115
|
-
it('
|
|
117
|
+
it('shifts our 0-based start by +1 so the new dimension lands AT start (after:false)', async () => {
|
|
118
|
+
// gog's positional <start> is 1-based and, without --after, it inserts *before* it,
|
|
119
|
+
// so API start_index = positional - 1. To land at our 0-based start we send start+1.
|
|
120
|
+
// start=5 → positional 6 → API start_index 5.
|
|
116
121
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
117
122
|
const handlers = setupHandlers();
|
|
118
123
|
await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'Sheet1', dimension: 'ROWS', start: 5 });
|
|
119
|
-
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'Sheet1', 'ROWS', '
|
|
124
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'Sheet1', 'ROWS', '6'], { account: undefined });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('bug: start=1, after:false inserts before 0-based index 1 (new row 2), not at the top', async () => {
|
|
128
|
+
// Regression for the reported "range.startIndex must be set" / wrong-placement bug.
|
|
129
|
+
// start=1 → positional 2 → gog API start_index 1 (insert before the 2nd row).
|
|
130
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
131
|
+
const handlers = setupHandlers();
|
|
132
|
+
await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'ROWS', start: 1, count: 1 });
|
|
133
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '2', '--count=1'], { account: undefined });
|
|
120
134
|
});
|
|
121
135
|
|
|
122
136
|
it('shifts start by +1 when after:true so the new dimension lands AFTER start', async () => {
|
|
@@ -140,14 +154,17 @@ describe('gog_sheets_insert', () => {
|
|
|
140
154
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
141
155
|
const handlers = setupHandlers();
|
|
142
156
|
await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'ROWS', start: 0, count: 0 });
|
|
143
|
-
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '
|
|
157
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '1', '--count=0'], { account: undefined });
|
|
144
158
|
});
|
|
145
159
|
|
|
146
|
-
it('omits --after and
|
|
160
|
+
it('omits --after and sends start+1 so start=0 inserts at the very top (after:false)', async () => {
|
|
161
|
+
// start=0 → positional 1 → gog API start_index 0. Sending positional 0 would make gog
|
|
162
|
+
// reject the call outright ("start must be >= 1"), so the +1 shift is what makes a
|
|
163
|
+
// top-of-sheet insert reachable at all.
|
|
147
164
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
148
165
|
const handlers = setupHandlers();
|
|
149
166
|
await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'ROWS', start: 0, after: false });
|
|
150
|
-
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '
|
|
167
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '1'], { account: undefined });
|
|
151
168
|
});
|
|
152
169
|
|
|
153
170
|
it('passes --inherit-from-before=true when set', async () => {
|
|
@@ -684,6 +701,39 @@ describe('gog_sheets_links', () => {
|
|
|
684
701
|
});
|
|
685
702
|
});
|
|
686
703
|
|
|
704
|
+
// gog 0.23.0
|
|
705
|
+
describe('gog_sheets_links_set', () => {
|
|
706
|
+
it('sets a single-cell hyperlink with cell/url/text positionals', async () => {
|
|
707
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
708
|
+
const handlers = setupHandlers();
|
|
709
|
+
await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cell: 'A1', url: 'https://x.com', text: 'Link' });
|
|
710
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
711
|
+
['sheets', 'links', 'set', 'sid', 'A1', 'https://x.com', 'Link'],
|
|
712
|
+
{ account: undefined },
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('sets a multi-link cell with --runs-json', async () => {
|
|
717
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
718
|
+
const handlers = setupHandlers();
|
|
719
|
+
await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cell: 'B2', runsJson: '[{"text":"A","uri":"https://a"}]' });
|
|
720
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
721
|
+
['sheets', 'links', 'set', 'sid', 'B2', '--runs-json=[{"text":"A","uri":"https://a"}]'],
|
|
722
|
+
{ account: undefined },
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('sets batch hyperlinks with --cells-json', async () => {
|
|
727
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
728
|
+
const handlers = setupHandlers();
|
|
729
|
+
await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cellsJson: '[{"cell":"A1","url":"https://a"}]' });
|
|
730
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
731
|
+
['sheets', 'links', 'set', 'sid', '--cells-json=[{"cell":"A1","url":"https://a"}]'],
|
|
732
|
+
{ account: undefined },
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
687
737
|
// 18. named-ranges list
|
|
688
738
|
describe('gog_sheets_named_ranges_list', () => {
|
|
689
739
|
it('calls runOrDiagnose with correct args', async () => {
|
|
@@ -980,11 +1030,82 @@ describe('gog_sheets_table_clear', () => {
|
|
|
980
1030
|
|
|
981
1031
|
// 35. table delete
|
|
982
1032
|
describe('gog_sheets_table_delete', () => {
|
|
983
|
-
it('
|
|
1033
|
+
it('keep_data=false deletes table (and data) directly with --discard-data --force', async () => {
|
|
984
1034
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
985
1035
|
const handlers = setupHandlers();
|
|
986
|
-
await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
987
|
-
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'table', 'delete', 'sid', 't1'], { account: undefined });
|
|
1036
|
+
await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1', keep_data: false });
|
|
1037
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'table', 'delete', 'sid', 't1', '--discard-data', '--force'], { account: undefined });
|
|
1038
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('keep_data (default) reads formulas, deletes, then restores the data', async () => {
|
|
1042
|
+
vi.mocked(lib.run)
|
|
1043
|
+
.mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}') // table get
|
|
1044
|
+
.mockResolvedValueOnce('{"values":[["Name","City","N"],["A","NYC","=1+1"]]}') // get --render=FORMULA
|
|
1045
|
+
.mockResolvedValueOnce('{"deleted":{"tableId":"t1"}}') // table delete
|
|
1046
|
+
.mockResolvedValueOnce('{"updatedRows":2}'); // update restore
|
|
1047
|
+
const handlers = setupHandlers();
|
|
1048
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1', account: 'a@b.com' });
|
|
1049
|
+
expect(lib.run).toHaveBeenNthCalledWith(1, ['sheets', 'table', 'get', 'sid', 't1'], { account: 'a@b.com' });
|
|
1050
|
+
expect(lib.run).toHaveBeenNthCalledWith(2, ['sheets', 'get', 'sid', 'Sheet1!A1:C2', '--render=FORMULA'], { account: 'a@b.com' });
|
|
1051
|
+
expect(lib.run).toHaveBeenNthCalledWith(3, ['sheets', 'table', 'delete', 'sid', 't1', '--discard-data', '--force'], { account: 'a@b.com' });
|
|
1052
|
+
expect(lib.run).toHaveBeenNthCalledWith(4, ['sheets', 'update', 'sid', 'Sheet1!A1:C2', '--values-json=[["Name","City","N"],["A","NYC","=1+1"]]'], { account: 'a@b.com' });
|
|
1053
|
+
expect(res.content[0].text).toContain('2 row');
|
|
1054
|
+
expect(res.content[0].text).toContain('Sheet1!A1:C2');
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('keep_data with an empty table skips the restore write', async () => {
|
|
1058
|
+
vi.mocked(lib.run)
|
|
1059
|
+
.mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C1"}}') // table get
|
|
1060
|
+
.mockResolvedValueOnce('{}') // get --render=FORMULA, no values key
|
|
1061
|
+
.mockResolvedValueOnce('{"deleted":{}}'); // table delete
|
|
1062
|
+
const handlers = setupHandlers();
|
|
1063
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
1064
|
+
expect(lib.run).toHaveBeenCalledTimes(3); // no update call
|
|
1065
|
+
expect(res.content[0].text).toContain('0 row');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it('aborts without deleting when the table has no a1 range', async () => {
|
|
1069
|
+
vi.mocked(lib.run).mockResolvedValueOnce('{"name":"Repro"}'); // table get, no a1
|
|
1070
|
+
vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed'));
|
|
1071
|
+
const handlers = setupHandlers();
|
|
1072
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
1073
|
+
expect(lib.run).toHaveBeenCalledTimes(1); // only the read, no delete
|
|
1074
|
+
expect(res.content[0].text).toBe('diagnosed');
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('aborts without deleting when the pre-delete read fails', async () => {
|
|
1078
|
+
vi.mocked(lib.run).mockRejectedValueOnce(new Error('read boom')); // table get throws
|
|
1079
|
+
vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed read'));
|
|
1080
|
+
const handlers = setupHandlers();
|
|
1081
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
1082
|
+
expect(lib.run).toHaveBeenCalledTimes(1);
|
|
1083
|
+
expect(res.content[0].text).toBe('diagnosed read');
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('diagnoses a delete failure leaving the data intact', async () => {
|
|
1087
|
+
vi.mocked(lib.run)
|
|
1088
|
+
.mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}')
|
|
1089
|
+
.mockResolvedValueOnce('{"values":[["A"]]}')
|
|
1090
|
+
.mockRejectedValueOnce(new Error('delete boom')); // table delete throws
|
|
1091
|
+
vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed delete'));
|
|
1092
|
+
const handlers = setupHandlers();
|
|
1093
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
1094
|
+
expect(res.content[0].text).toBe('diagnosed delete');
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('surfaces the read-back values when restore fails after deletion', async () => {
|
|
1098
|
+
vi.mocked(lib.run)
|
|
1099
|
+
.mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}')
|
|
1100
|
+
.mockResolvedValueOnce('{"values":[["keepme"]]}')
|
|
1101
|
+
.mockResolvedValueOnce('{"deleted":{}}')
|
|
1102
|
+
.mockRejectedValueOnce(new Error('restore boom')); // update throws
|
|
1103
|
+
const handlers = setupHandlers();
|
|
1104
|
+
const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
|
|
1105
|
+
const text = res.content[0].text;
|
|
1106
|
+
expect(text).toContain('restoring its data FAILED');
|
|
1107
|
+
expect(text).toContain('keepme'); // the data is handed back for manual recovery
|
|
1108
|
+
expect(text).toContain('restore boom');
|
|
988
1109
|
});
|
|
989
1110
|
});
|
|
990
1111
|
|
|
@@ -1146,3 +1267,20 @@ describe('gog_sheets_conditional_format_clear', () => {
|
|
|
1146
1267
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'conditional-format', 'clear', 'sid', '--sheet=Data', '--index=2'], { account: undefined });
|
|
1147
1268
|
});
|
|
1148
1269
|
});
|
|
1270
|
+
|
|
1271
|
+
// 49. snapshot (backup before destructive edits)
|
|
1272
|
+
describe('gog_sheets_snapshot', () => {
|
|
1273
|
+
it('copies the spreadsheet to a named backup', async () => {
|
|
1274
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
1275
|
+
const handlers = setupHandlers();
|
|
1276
|
+
await handlers.get('gog_sheets_snapshot')!({ spreadsheetId: 'sid', name: 'Backup A' });
|
|
1277
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'copy', 'sid', 'Backup A'], { account: undefined });
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it('includes --parent and forwards account', async () => {
|
|
1281
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
|
|
1282
|
+
const handlers = setupHandlers();
|
|
1283
|
+
await handlers.get('gog_sheets_snapshot')!({ spreadsheetId: 'sid', name: 'Backup A', parent: 'folder1', account: 'a@b.com' });
|
|
1284
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'copy', 'sid', 'Backup A', '--parent=folder1'], { account: 'a@b.com' });
|
|
1285
|
+
});
|
|
1286
|
+
});
|