gogcli-mcp-sheets 2.5.0 → 2.6.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.
@@ -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.5.0"
10
+ "version": "2.6.0"
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.5.0",
18
+ "version": "2.6.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp-sheets",
3
3
  "displayName": "gogcli (Sheets)",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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.5.0" : "0.0.0";
31402
+ var VERSION = true ? "2.6.0" : "0.0.0";
31403
31403
  function createServer(options) {
31404
31404
  return new McpServer({
31405
31405
  name: options?.name ?? "gogcli",
@@ -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 its table ID.",
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
- return runOrDiagnose(["sheets", "table", "delete", spreadsheetId, tableId], { account });
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.5.0",
6
+ "version": "2.6.0",
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 by table ID."
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.5.0",
3
+ "version": "2.6.0",
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.5.0",
10
+ "version": "2.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "2.5.0",
15
+ "version": "2.6.0",
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.
@@ -440,6 +440,31 @@ export function registerExtraSheetsTools(server: McpServer): void {
440
440
  return runOrDiagnose(['sheets', 'links', spreadsheetId, range], { account });
441
441
  });
442
442
 
443
+ server.registerTool('gog_sheets_links_set', {
444
+ description:
445
+ 'Set cell hyperlinks in a Google Sheet. Three modes: (1) single link — pass cell + url (+ optional text); ' +
446
+ '(2) multi-link cell — pass cell + runsJson, a JSON array of rich-text runs (a run with an empty uri is plain text); ' +
447
+ '(3) batch — pass cellsJson, a JSON array of {cell,url,text} or {cell,runs:[...]} objects written in one request.',
448
+ annotations: { destructiveHint: true },
449
+ inputSchema: {
450
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
451
+ 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).'),
452
+ url: z.string().optional().describe('Hyperlink URL for single-link mode'),
453
+ text: z.string().optional().describe('Display text for single-link mode (defaults to the URL when omitted)'),
454
+ 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.'),
455
+ cellsJson: z.string().optional().describe('Batch: JSON array of {cell,url,text} or {cell,runs:[{text,uri}]} objects, written in one request.'),
456
+ account: accountParam,
457
+ },
458
+ }, async ({ spreadsheetId, cell, url, text, runsJson, cellsJson, account }) => {
459
+ const args = ['sheets', 'links', 'set', spreadsheetId];
460
+ if (cell) args.push(cell);
461
+ if (url) args.push(url);
462
+ if (text) args.push(text);
463
+ if (runsJson) args.push(`--runs-json=${runsJson}`);
464
+ if (cellsJson) args.push(`--cells-json=${cellsJson}`);
465
+ return runOrDiagnose(args, { account });
466
+ });
467
+
443
468
  server.registerTool('gog_sheets_named_ranges_list', {
444
469
  description: 'List all named ranges in a spreadsheet.',
445
470
  annotations: { readOnlyHint: true },
@@ -683,15 +708,70 @@ export function registerExtraSheetsTools(server: McpServer): void {
683
708
  });
684
709
 
685
710
  server.registerTool('gog_sheets_table_delete', {
686
- description: 'Delete a table by its table ID.',
711
+ description:
712
+ '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. ' +
713
+ '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. ' +
714
+ 'Formatting, banding, and data-validation dropdowns are still lost (gog cannot round-trip those yet). ' +
715
+ '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
716
  annotations: { destructiveHint: true },
688
717
  inputSchema: {
689
718
  spreadsheetId: z.string().describe('Spreadsheet ID'),
690
719
  tableId: z.string().describe('Table ID to delete'),
720
+ keep_data: z.boolean().optional().describe(
721
+ '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.',
722
+ ),
691
723
  account: accountParam,
692
724
  },
693
- }, async ({ spreadsheetId, tableId, account }) => {
694
- return runOrDiagnose(['sheets', 'table', 'delete', spreadsheetId, tableId], { account });
725
+ }, async ({ spreadsheetId, tableId, keep_data = true, account }) => {
726
+ if (!keep_data) {
727
+ // Raw destructive delete: removes the table and its cell data. gog ≥ 0.23
728
+ // requires --discard-data to confirm the cell wipe (--force only skips the
729
+ // interactive confirmation; it does not authorize the data loss).
730
+ return runOrDiagnose(['sheets', 'table', 'delete', spreadsheetId, tableId, '--discard-data', '--force'], { account });
731
+ }
732
+
733
+ // keep_data: emulate "Convert to range". Read the full table range with
734
+ // formulas intact, then delete, then write the data back. If we can't read
735
+ // the data first, refuse to delete — losing the backup is the whole risk.
736
+ let range: string;
737
+ let values: unknown[][];
738
+ try {
739
+ // `gog sheets table get` wraps the table under a top-level "table" key.
740
+ const parsed = JSON.parse(await run(['sheets', 'table', 'get', spreadsheetId, tableId], { account }));
741
+ range = parsed.table?.a1;
742
+ if (!range) throw new Error(`could not determine the table's range (no "a1" in table get output) for table ${tableId}`);
743
+ const read = JSON.parse(await run(['sheets', 'get', spreadsheetId, range, '--render=FORMULA'], { account }));
744
+ values = read.values ?? [];
745
+ } catch (err) {
746
+ return diagnose(err);
747
+ }
748
+
749
+ try {
750
+ // We have already backed up the cells above, so --discard-data (required
751
+ // by gog ≥ 0.23) is the intended path here — we re-write the data right
752
+ // after.
753
+ await run(['sheets', 'table', 'delete', spreadsheetId, tableId, '--discard-data', '--force'], { account });
754
+ } catch (err) {
755
+ // Table not deleted; cell data is untouched.
756
+ return diagnose(err);
757
+ }
758
+
759
+ if (values.length > 0) {
760
+ try {
761
+ await run(['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`], { account });
762
+ } catch (err) {
763
+ // The table is already gone but the restore write failed. Hand the
764
+ // read-back data straight back so it can be re-applied manually rather
765
+ // than silently lost.
766
+ return toText(
767
+ `Table ${tableId} was deleted, but restoring its data FAILED — re-apply the values below manually.\n\n` +
768
+ `Range: ${range}\nValues: ${JSON.stringify(values)}\n\n` +
769
+ `${toError(err).content[0].text}`,
770
+ );
771
+ }
772
+ }
773
+
774
+ return toText(`Deleted table ${tableId} and preserved ${values.length} row(s) of data (values + formulas) in ${range}.`);
695
775
  });
696
776
 
697
777
  // ---- Banding / alternating colors (gog 0.19.0) ----
@@ -798,4 +878,21 @@ export function registerExtraSheetsTools(server: McpServer): void {
798
878
  if (all) args.push('--all');
799
879
  return runOrDiagnose(args, { account });
800
880
  });
881
+
882
+ server.registerTool('gog_sheets_snapshot', {
883
+ description:
884
+ '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). ' +
885
+ '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.',
886
+ inputSchema: {
887
+ spreadsheetId: z.string().describe('Spreadsheet ID to back up'),
888
+ name: z.string().describe('Name for the backup copy, e.g. "Budget — backup before table delete"'),
889
+ parent: z.string().optional().describe('Destination folder ID for the copy (default: same location as the original)'),
890
+ account: accountParam,
891
+ },
892
+ }, async ({ spreadsheetId, name, parent, account }) => {
893
+ const args = ['drive', 'copy', spreadsheetId, name];
894
+ if (parent) args.push(`--parent=${parent}`);
895
+ return runOrDiagnose(args, { account });
896
+ });
897
+
801
898
  }
@@ -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
 
@@ -684,6 +686,39 @@ describe('gog_sheets_links', () => {
684
686
  });
685
687
  });
686
688
 
689
+ // gog 0.23.0
690
+ describe('gog_sheets_links_set', () => {
691
+ it('sets a single-cell hyperlink with cell/url/text positionals', async () => {
692
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
693
+ const handlers = setupHandlers();
694
+ await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cell: 'A1', url: 'https://x.com', text: 'Link' });
695
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
696
+ ['sheets', 'links', 'set', 'sid', 'A1', 'https://x.com', 'Link'],
697
+ { account: undefined },
698
+ );
699
+ });
700
+
701
+ it('sets a multi-link cell with --runs-json', async () => {
702
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
703
+ const handlers = setupHandlers();
704
+ await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cell: 'B2', runsJson: '[{"text":"A","uri":"https://a"}]' });
705
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
706
+ ['sheets', 'links', 'set', 'sid', 'B2', '--runs-json=[{"text":"A","uri":"https://a"}]'],
707
+ { account: undefined },
708
+ );
709
+ });
710
+
711
+ it('sets batch hyperlinks with --cells-json', async () => {
712
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
713
+ const handlers = setupHandlers();
714
+ await handlers.get('gog_sheets_links_set')!({ spreadsheetId: 'sid', cellsJson: '[{"cell":"A1","url":"https://a"}]' });
715
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
716
+ ['sheets', 'links', 'set', 'sid', '--cells-json=[{"cell":"A1","url":"https://a"}]'],
717
+ { account: undefined },
718
+ );
719
+ });
720
+ });
721
+
687
722
  // 18. named-ranges list
688
723
  describe('gog_sheets_named_ranges_list', () => {
689
724
  it('calls runOrDiagnose with correct args', async () => {
@@ -980,11 +1015,82 @@ describe('gog_sheets_table_clear', () => {
980
1015
 
981
1016
  // 35. table delete
982
1017
  describe('gog_sheets_table_delete', () => {
983
- it('calls runOrDiagnose with correct args', async () => {
1018
+ it('keep_data=false deletes table (and data) directly with --discard-data --force', async () => {
984
1019
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
985
1020
  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 });
1021
+ await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1', keep_data: false });
1022
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'table', 'delete', 'sid', 't1', '--discard-data', '--force'], { account: undefined });
1023
+ expect(lib.run).not.toHaveBeenCalled();
1024
+ });
1025
+
1026
+ it('keep_data (default) reads formulas, deletes, then restores the data', async () => {
1027
+ vi.mocked(lib.run)
1028
+ .mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}') // table get
1029
+ .mockResolvedValueOnce('{"values":[["Name","City","N"],["A","NYC","=1+1"]]}') // get --render=FORMULA
1030
+ .mockResolvedValueOnce('{"deleted":{"tableId":"t1"}}') // table delete
1031
+ .mockResolvedValueOnce('{"updatedRows":2}'); // update restore
1032
+ const handlers = setupHandlers();
1033
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1', account: 'a@b.com' });
1034
+ expect(lib.run).toHaveBeenNthCalledWith(1, ['sheets', 'table', 'get', 'sid', 't1'], { account: 'a@b.com' });
1035
+ expect(lib.run).toHaveBeenNthCalledWith(2, ['sheets', 'get', 'sid', 'Sheet1!A1:C2', '--render=FORMULA'], { account: 'a@b.com' });
1036
+ expect(lib.run).toHaveBeenNthCalledWith(3, ['sheets', 'table', 'delete', 'sid', 't1', '--discard-data', '--force'], { account: 'a@b.com' });
1037
+ expect(lib.run).toHaveBeenNthCalledWith(4, ['sheets', 'update', 'sid', 'Sheet1!A1:C2', '--values-json=[["Name","City","N"],["A","NYC","=1+1"]]'], { account: 'a@b.com' });
1038
+ expect(res.content[0].text).toContain('2 row');
1039
+ expect(res.content[0].text).toContain('Sheet1!A1:C2');
1040
+ });
1041
+
1042
+ it('keep_data with an empty table skips the restore write', async () => {
1043
+ vi.mocked(lib.run)
1044
+ .mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C1"}}') // table get
1045
+ .mockResolvedValueOnce('{}') // get --render=FORMULA, no values key
1046
+ .mockResolvedValueOnce('{"deleted":{}}'); // table delete
1047
+ const handlers = setupHandlers();
1048
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
1049
+ expect(lib.run).toHaveBeenCalledTimes(3); // no update call
1050
+ expect(res.content[0].text).toContain('0 row');
1051
+ });
1052
+
1053
+ it('aborts without deleting when the table has no a1 range', async () => {
1054
+ vi.mocked(lib.run).mockResolvedValueOnce('{"name":"Repro"}'); // table get, no a1
1055
+ vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed'));
1056
+ const handlers = setupHandlers();
1057
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
1058
+ expect(lib.run).toHaveBeenCalledTimes(1); // only the read, no delete
1059
+ expect(res.content[0].text).toBe('diagnosed');
1060
+ });
1061
+
1062
+ it('aborts without deleting when the pre-delete read fails', async () => {
1063
+ vi.mocked(lib.run).mockRejectedValueOnce(new Error('read boom')); // table get throws
1064
+ vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed read'));
1065
+ const handlers = setupHandlers();
1066
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
1067
+ expect(lib.run).toHaveBeenCalledTimes(1);
1068
+ expect(res.content[0].text).toBe('diagnosed read');
1069
+ });
1070
+
1071
+ it('diagnoses a delete failure leaving the data intact', async () => {
1072
+ vi.mocked(lib.run)
1073
+ .mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}')
1074
+ .mockResolvedValueOnce('{"values":[["A"]]}')
1075
+ .mockRejectedValueOnce(new Error('delete boom')); // table delete throws
1076
+ vi.mocked(lib.diagnose).mockResolvedValue(toText('diagnosed delete'));
1077
+ const handlers = setupHandlers();
1078
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
1079
+ expect(res.content[0].text).toBe('diagnosed delete');
1080
+ });
1081
+
1082
+ it('surfaces the read-back values when restore fails after deletion', async () => {
1083
+ vi.mocked(lib.run)
1084
+ .mockResolvedValueOnce('{"table":{"a1":"Sheet1!A1:C2"}}')
1085
+ .mockResolvedValueOnce('{"values":[["keepme"]]}')
1086
+ .mockResolvedValueOnce('{"deleted":{}}')
1087
+ .mockRejectedValueOnce(new Error('restore boom')); // update throws
1088
+ const handlers = setupHandlers();
1089
+ const res = await handlers.get('gog_sheets_table_delete')!({ spreadsheetId: 'sid', tableId: 't1' });
1090
+ const text = res.content[0].text;
1091
+ expect(text).toContain('restoring its data FAILED');
1092
+ expect(text).toContain('keepme'); // the data is handed back for manual recovery
1093
+ expect(text).toContain('restore boom');
988
1094
  });
989
1095
  });
990
1096
 
@@ -1146,3 +1252,20 @@ describe('gog_sheets_conditional_format_clear', () => {
1146
1252
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'conditional-format', 'clear', 'sid', '--sheet=Data', '--index=2'], { account: undefined });
1147
1253
  });
1148
1254
  });
1255
+
1256
+ // 49. snapshot (backup before destructive edits)
1257
+ describe('gog_sheets_snapshot', () => {
1258
+ it('copies the spreadsheet to a named backup', async () => {
1259
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
1260
+ const handlers = setupHandlers();
1261
+ await handlers.get('gog_sheets_snapshot')!({ spreadsheetId: 'sid', name: 'Backup A' });
1262
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'copy', 'sid', 'Backup A'], { account: undefined });
1263
+ });
1264
+
1265
+ it('includes --parent and forwards account', async () => {
1266
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
1267
+ const handlers = setupHandlers();
1268
+ await handlers.get('gog_sheets_snapshot')!({ spreadsheetId: 'sid', name: 'Backup A', parent: 'folder1', account: 'a@b.com' });
1269
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'copy', 'sid', 'Backup A', '--parent=folder1'], { account: 'a@b.com' });
1270
+ });
1271
+ });