gogcli-mcp-sheets 2.0.12 → 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.
@@ -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.0.12"
10
+ "version": "2.2.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.0.12",
18
+ "version": "2.2.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.0.12",
4
+ "version": "2.2.0",
5
5
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/CHANGELOG.md ADDED
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ ## [2.3.0](https://github.com/chrischall/gogcli-mcp/compare/v2.2.0...v2.3.0) (2026-05-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * add manifest.json and SKILL.md to sub-packages for .mcpb and .skill builds ([d1339c6](https://github.com/chrischall/gogcli-mcp/commit/d1339c690b1eeee8221b7cc15fd9e4fa48c1e20a))
9
+ * address open issues for docs + sheets ([7109253](https://github.com/chrischall/gogcli-mcp/commit/7109253b3eb4b3dcc17cda7a8dfb1b6969023bcf))
10
+ * **deploy:** per-sub-package registry listings (MCP Registry, ClawHub, Claude plugins) ([6cc1798](https://github.com/chrischall/gogcli-mcp/commit/6cc1798320af6dd3af709790e6bec30f46c5f3d2))
11
+ * **drive:** add gogcli-mcp-drive sub-package ([d8eef61](https://github.com/chrischall/gogcli-mcp/commit/d8eef61f16266209da70cd05ca0d2378bf3c4613))
12
+ * **sheets:** warn when DATE/DATE_TIME format applied to small integers ([#46](https://github.com/chrischall/gogcli-mcp/issues/46)) ([623c432](https://github.com/chrischall/gogcli-mcp/commit/623c432998d94875706733d4140b346de74ef9d8))
13
+ * wrap new gog cli v0.18.0 tools ([173242a](https://github.com/chrischall/gogcli-mcp/commit/173242aafaa8c2f2d7282a2fbd8faedfc65a63bd))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * add repository field to sub-packages for npm provenance ([3b56e4b](https://github.com/chrischall/gogcli-mcp/commit/3b56e4bb56617ad8381997176e6f60c932865454))
19
+ * **mcpb:** add per-sub-package .mcpbignore to trim bundles ([e739d9e](https://github.com/chrischall/gogcli-mcp/commit/e739d9ec9ae8e4cc19b1189bb5bf7ac90481c943))
20
+ * **sheets:** gog_sheets_insert with after:true now actually shifts the insertion point ([#44](https://github.com/chrischall/gogcli-mcp/issues/44)) ([a766d80](https://github.com/chrischall/gogcli-mcp/commit/a766d8065993e764a8ed35965e506af472eafa89))
21
+
22
+
23
+ ### Refactor
24
+
25
+ * aggressive cleanup pass (audit findings) ([e635b39](https://github.com/chrischall/gogcli-mcp/commit/e635b391cf611e9c102a422ffbce5ebe28687b80))
26
+ * make sub-packages focused (auth + service only) ([4301306](https://github.com/chrischall/gogcli-mcp/commit/4301306cecf12e66d67dbc4ce9debae8f3dcfabc))
27
+ * restructure into npm workspaces monorepo ([9645100](https://github.com/chrischall/gogcli-mcp/commit/9645100e8120c03956ee972023b12d2e99876cc5))
28
+ * review-pass cleanup (enums, shared schemas, test harness) ([ea851de](https://github.com/chrischall/gogcli-mcp/commit/ea851deb30b9cfef3f07ed506a011fc363b34018))
29
+ * use relative imports instead of workspace symlinks ([a278d6f](https://github.com/chrischall/gogcli-mcp/commit/a278d6fb31be480571481e9d87e199d261a3f031))
30
+
31
+
32
+ ### Documentation
33
+
34
+ * add per-package READMEs, update root README and CLAUDE.md for monorepo ([19d547e](https://github.com/chrischall/gogcli-mcp/commit/19d547e5ceb14298a75ab297d0a9a11c6ea59e24))
35
+ * fix tool counts and stale descriptions across all docs ([3558c72](https://github.com/chrischall/gogcli-mcp/commit/3558c72fa5c3ee3dbc8a06e9d7fb51bf73ef77c7))
36
+ * update gogcli repo URLs to openclaw/gogcli (was steipete/gogcli) ([951a181](https://github.com/chrischall/gogcli-mcp/commit/951a1818ebc269f203aee723ccbea32c13817d8b))
37
+
38
+ ## [2.2.0](https://github.com/chrischall/gogcli-mcp/compare/v2.1.0...v2.2.0) (2026-05-26)
39
+
40
+
41
+ ### Features
42
+
43
+ * **sheets:** warn when DATE/DATE_TIME format applied to small integers ([#46](https://github.com/chrischall/gogcli-mcp/issues/46)) ([623c432](https://github.com/chrischall/gogcli-mcp/commit/623c432998d94875706733d4140b346de74ef9d8))
44
+
45
+
46
+ ### Bug Fixes
47
+
48
+ * **sheets:** gog_sheets_insert with after:true now actually shifts the insertion point ([#44](https://github.com/chrischall/gogcli-mcp/issues/44)) ([a766d80](https://github.com/chrischall/gogcli-mcp/commit/a766d8065993e764a8ed35965e506af472eafa89))
49
+
50
+ ## [2.1.0](https://github.com/chrischall/gogcli-mcp/compare/v2.0.13...v2.1.0) (2026-05-25)
51
+
52
+
53
+ ### Features
54
+
55
+ * add manifest.json and SKILL.md to sub-packages for .mcpb and .skill builds ([d1339c6](https://github.com/chrischall/gogcli-mcp/commit/d1339c690b1eeee8221b7cc15fd9e4fa48c1e20a))
56
+ * address open issues for docs + sheets ([7109253](https://github.com/chrischall/gogcli-mcp/commit/7109253b3eb4b3dcc17cda7a8dfb1b6969023bcf))
57
+ * **deploy:** per-sub-package registry listings (MCP Registry, ClawHub, Claude plugins) ([6cc1798](https://github.com/chrischall/gogcli-mcp/commit/6cc1798320af6dd3af709790e6bec30f46c5f3d2))
58
+ * **drive:** add gogcli-mcp-drive sub-package ([d8eef61](https://github.com/chrischall/gogcli-mcp/commit/d8eef61f16266209da70cd05ca0d2378bf3c4613))
59
+ * wrap new gog cli v0.18.0 tools ([173242a](https://github.com/chrischall/gogcli-mcp/commit/173242aafaa8c2f2d7282a2fbd8faedfc65a63bd))
60
+
61
+
62
+ ### Bug Fixes
63
+
64
+ * add repository field to sub-packages for npm provenance ([3b56e4b](https://github.com/chrischall/gogcli-mcp/commit/3b56e4bb56617ad8381997176e6f60c932865454))
65
+ * **mcpb:** add per-sub-package .mcpbignore to trim bundles ([e739d9e](https://github.com/chrischall/gogcli-mcp/commit/e739d9ec9ae8e4cc19b1189bb5bf7ac90481c943))
66
+
67
+
68
+ ### Refactor
69
+
70
+ * aggressive cleanup pass (audit findings) ([e635b39](https://github.com/chrischall/gogcli-mcp/commit/e635b391cf611e9c102a422ffbce5ebe28687b80))
71
+ * make sub-packages focused (auth + service only) ([4301306](https://github.com/chrischall/gogcli-mcp/commit/4301306cecf12e66d67dbc4ce9debae8f3dcfabc))
72
+ * restructure into npm workspaces monorepo ([9645100](https://github.com/chrischall/gogcli-mcp/commit/9645100e8120c03956ee972023b12d2e99876cc5))
73
+ * review-pass cleanup (enums, shared schemas, test harness) ([ea851de](https://github.com/chrischall/gogcli-mcp/commit/ea851deb30b9cfef3f07ed506a011fc363b34018))
74
+ * use relative imports instead of workspace symlinks ([a278d6f](https://github.com/chrischall/gogcli-mcp/commit/a278d6fb31be480571481e9d87e199d261a3f031))
75
+
76
+
77
+ ### Documentation
78
+
79
+ * add per-package READMEs, update root README and CLAUDE.md for monorepo ([19d547e](https://github.com/chrischall/gogcli-mcp/commit/19d547e5ceb14298a75ab297d0a9a11c6ea59e24))
80
+ * fix tool counts and stale descriptions across all docs ([3558c72](https://github.com/chrischall/gogcli-mcp/commit/3558c72fa5c3ee3dbc8a06e9d7fb51bf73ef77c7))
81
+ * update gogcli repo URLs to openclaw/gogcli (was steipete/gogcli) ([951a181](https://github.com/chrischall/gogcli-mcp/commit/951a1818ebc269f203aee723ccbea32c13817d8b))
package/dist/index.js CHANGED
@@ -31074,7 +31074,7 @@ async function run(args, options = {}) {
31074
31074
 
31075
31075
  // ../gogcli-mcp/src/tools/utils.ts
31076
31076
  var accountParam = external_exports.string().optional().describe(
31077
- "Google account email to use (overrides GOG_ACCOUNT env var)"
31077
+ "Google account email to use, e.g. you@gmail.com \u2014 must be the full address, not a bare username. Overrides the GOG_ACCOUNT env var. Omit to use the single configured account."
31078
31078
  );
31079
31079
  var ids = {
31080
31080
  course: external_exports.string().describe("Course ID"),
@@ -31133,26 +31133,41 @@ function toError(err) {
31133
31133
  }
31134
31134
  var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
31135
31135
  var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
31136
+ var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
31136
31137
  var AUTH_HINT = "\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. Ask the user if they would like to re-authenticate.";
31137
31138
  var TRANSIENT_HINT = "\n\nThis error is often transient. Retry the same call before trying a different approach (do not fall back to smaller writes or row-by-row operations).";
31139
+ var GRID_LIMIT_HINT = "\n\nThe target range is outside the sheet's current grid. Add the missing rows or columns first with gog_sheets_insert (dimension: rows or cols), then retry the write.";
31140
+ function formatAccountList(raw) {
31141
+ try {
31142
+ const parsed = JSON.parse(raw);
31143
+ if (Array.isArray(parsed?.accounts)) {
31144
+ return parsed.accounts.map((a) => a?.email).filter(Boolean).join("\n");
31145
+ }
31146
+ } catch {
31147
+ }
31148
+ return raw.trim();
31149
+ }
31150
+ async function diagnose(err) {
31151
+ const errText = toError(err).content[0].text;
31152
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31153
+ const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31154
+ const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
31155
+ const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
31156
+ try {
31157
+ const accounts = formatAccountList(await run(["auth", "list"]));
31158
+ return toText(`${errText}
31159
+
31160
+ Configured accounts:
31161
+ ${accounts || "(none)"}${hint}`);
31162
+ } catch {
31163
+ return toText(`${errText}${hint}`);
31164
+ }
31165
+ }
31138
31166
  async function runOrDiagnose(args, options) {
31139
31167
  try {
31140
31168
  return toText(await run(args, options));
31141
31169
  } catch (err) {
31142
- const base = toError(err);
31143
- const errText = base.content[0].text;
31144
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31145
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31146
- const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : "";
31147
- try {
31148
- const accounts = await run(["auth", "list"]);
31149
- return toText(`${errText}
31150
-
31151
- Configured accounts:
31152
- ${accounts}${hint}`);
31153
- } catch {
31154
- return toText(`${errText}${hint}`);
31155
- }
31170
+ return diagnose(err);
31156
31171
  }
31157
31172
  }
31158
31173
 
@@ -31218,8 +31233,62 @@ function registerAuthTools(server2) {
31218
31233
  });
31219
31234
  }
31220
31235
 
31236
+ // ../gogcli-mcp/src/tools/sheets-a1.ts
31237
+ function colToLetter(n) {
31238
+ let s = "";
31239
+ while (n > 0) {
31240
+ const rem = (n - 1) % 26;
31241
+ s = String.fromCharCode(65 + rem) + s;
31242
+ n = Math.floor((n - 1) / 26);
31243
+ }
31244
+ return s;
31245
+ }
31246
+ function letterToCol(s) {
31247
+ let n = 0;
31248
+ for (const ch of s.toUpperCase()) {
31249
+ n = n * 26 + (ch.charCodeAt(0) - 64);
31250
+ }
31251
+ return n;
31252
+ }
31253
+ function expandAnchorRange(range, rows, cols) {
31254
+ const bang = range.lastIndexOf("!");
31255
+ const sheet = bang >= 0 ? range.slice(0, bang + 1) : "";
31256
+ const cell = bang >= 0 ? range.slice(bang + 1) : range;
31257
+ const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
31258
+ if (!m) return range;
31259
+ const startCol = letterToCol(m[1]);
31260
+ const startRow = parseInt(m[2], 10);
31261
+ const endCol = colToLetter(startCol + cols - 1);
31262
+ const endRow = startRow + rows - 1;
31263
+ return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
31264
+ }
31265
+ function countNonEmptyCells(getOutput) {
31266
+ let parsed;
31267
+ try {
31268
+ parsed = JSON.parse(getOutput);
31269
+ } catch {
31270
+ return -1;
31271
+ }
31272
+ const values = parsed?.values;
31273
+ if (!Array.isArray(values)) return 0;
31274
+ let count = 0;
31275
+ for (const row of values) {
31276
+ if (!Array.isArray(row)) continue;
31277
+ for (const cell of row) {
31278
+ if (cell !== null && String(cell).trim() !== "") count++;
31279
+ }
31280
+ }
31281
+ return count;
31282
+ }
31283
+
31221
31284
  // ../gogcli-mcp/src/tools/sheets.ts
31222
31285
  var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
31286
+ var dryRunParam = external_exports.boolean().optional().describe(
31287
+ "Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
31288
+ );
31289
+ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31290
+ '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.'
31291
+ );
31223
31292
  function registerSheetsTools(server2) {
31224
31293
  server2.registerTool("gog_sheets_get", {
31225
31294
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -31239,13 +31308,31 @@ function registerSheetsTools(server2) {
31239
31308
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31240
31309
  range: external_exports.string().describe("Top-left cell or range in A1 notation, e.g. Sheet1!A1"),
31241
31310
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
31311
+ dry_run: dryRunParam,
31312
+ fail_if_not_empty: failIfNotEmptyParam,
31242
31313
  account: accountParam
31243
31314
  }
31244
- }, async ({ spreadsheetId, range, values, account }) => {
31245
- return runOrDiagnose(
31246
- ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
31247
- { account }
31248
- );
31315
+ }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
31316
+ const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
31317
+ if (fail_if_not_empty && values.length > 0 && cols > 0) {
31318
+ const readRange = expandAnchorRange(range, values.length, cols);
31319
+ let existing;
31320
+ try {
31321
+ existing = await run(["sheets", "get", spreadsheetId, readRange], { account });
31322
+ } catch (err) {
31323
+ return diagnose(err);
31324
+ }
31325
+ const occupied = countNonEmptyCells(existing);
31326
+ if (occupied !== 0) {
31327
+ const detail = occupied < 0 ? "could not be verified as empty" : `already contains data in ${occupied} cell(s)`;
31328
+ return toText(
31329
+ `Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.`
31330
+ );
31331
+ }
31332
+ }
31333
+ const args = ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
31334
+ if (dry_run) args.push("--dry-run");
31335
+ return runOrDiagnose(args, { account });
31249
31336
  });
31250
31337
  server2.registerTool("gog_sheets_append", {
31251
31338
  description: 'Append rows to a Google Sheet after the last row with data in the given range. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas.',
@@ -31254,13 +31341,13 @@ function registerSheetsTools(server2) {
31254
31341
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31255
31342
  range: external_exports.string().describe("Range indicating which sheet/columns to append to, e.g. Sheet1!A:C"),
31256
31343
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
31344
+ dry_run: dryRunParam,
31257
31345
  account: accountParam
31258
31346
  }
31259
- }, async ({ spreadsheetId, range, values, account }) => {
31260
- return runOrDiagnose(
31261
- ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
31262
- { account }
31263
- );
31347
+ }, async ({ spreadsheetId, range, values, account, dry_run }) => {
31348
+ const args = ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
31349
+ if (dry_run) args.push("--dry-run");
31350
+ return runOrDiagnose(args, { account });
31264
31351
  });
31265
31352
  server2.registerTool("gog_sheets_clear", {
31266
31353
  description: "Clear all values in a Google Sheets range (formatting is preserved).",
@@ -31268,13 +31355,16 @@ function registerSheetsTools(server2) {
31268
31355
  inputSchema: {
31269
31356
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
31270
31357
  range: external_exports.string().describe("Range in A1 notation to clear"),
31358
+ dry_run: dryRunParam,
31271
31359
  account: accountParam
31272
31360
  }
31273
- }, async ({ spreadsheetId, range, account }) => {
31274
- return runOrDiagnose(["sheets", "clear", spreadsheetId, range], { account });
31361
+ }, async ({ spreadsheetId, range, account, dry_run }) => {
31362
+ const args = ["sheets", "clear", spreadsheetId, range];
31363
+ if (dry_run) args.push("--dry-run");
31364
+ return runOrDiagnose(args, { account });
31275
31365
  });
31276
31366
  server2.registerTool("gog_sheets_metadata", {
31277
- description: "Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.",
31367
+ 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 \u2014 a write outside the grid fails.",
31278
31368
  annotations: { readOnlyHint: true },
31279
31369
  inputSchema: {
31280
31370
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
@@ -31309,7 +31399,7 @@ function registerSheetsTools(server2) {
31309
31399
  }
31310
31400
 
31311
31401
  // ../gogcli-mcp/src/server.ts
31312
- var VERSION = true ? "2.0.12" : "0.0.0";
31402
+ var VERSION = true ? "2.2.0" : "0.0.0";
31313
31403
  function createServer(options) {
31314
31404
  return new McpServer({
31315
31405
  name: options?.name ?? "gogcli",
@@ -31329,6 +31419,33 @@ function hexToRgb(hex3) {
31329
31419
  blue: (n & 255) / 255
31330
31420
  };
31331
31421
  }
31422
+ async function checkDateFormatTarget(spreadsheetId, range, account) {
31423
+ const peek = await runOrDiagnose(
31424
+ ["sheets", "get", spreadsheetId, range, "--render=UNFORMATTED_VALUE"],
31425
+ { account }
31426
+ );
31427
+ let parsed;
31428
+ try {
31429
+ parsed = JSON.parse(peek.content[0].text);
31430
+ } catch {
31431
+ return null;
31432
+ }
31433
+ const rows = parsed.values;
31434
+ if (!Array.isArray(rows) || rows.length === 0) return null;
31435
+ let sawSmallInt = false;
31436
+ for (const row of rows) {
31437
+ for (const cell of row) {
31438
+ if (cell === null || cell === void 0 || cell === "") continue;
31439
+ if (typeof cell !== "number") return null;
31440
+ if (!Number.isInteger(cell)) return null;
31441
+ if (cell < 0) return null;
31442
+ if (cell >= 1e4) return null;
31443
+ sawSmallInt = true;
31444
+ }
31445
+ }
31446
+ if (!sawSmallInt) return null;
31447
+ return "Warning: applying DATE/DATE_TIME format to cells holding small integers (< 10000) will render them as dates near 1899-12-30 because Sheets interprets numeric values as day-serials from that epoch. If those integers are ordinals (1, 2, 3, ...) and not day offsets, this is almost certainly not what you want. Pass force:true to suppress this warning, or convert the cells to real dates / strings first.";
31448
+ }
31332
31449
  function registerExtraSheetsTools(server2) {
31333
31450
  server2.registerTool("gog_sheets_list_tabs", {
31334
31451
  description: "List tabs (sheets) in a spreadsheet with their titles, sheetIds, and indices. A friendlier view than gog_sheets_metadata when you only need the tab list \u2014 useful for restructuring a workbook over a long agent session without losing track of names.",
@@ -31422,7 +31539,7 @@ function registerExtraSheetsTools(server2) {
31422
31539
  return runOrDiagnose(args, { account });
31423
31540
  });
31424
31541
  server2.registerTool("gog_sheets_insert", {
31425
- description: "Insert rows or columns into a sheet.",
31542
+ description: "Insert rows or columns into a sheet. With after:false (default), the new dimension lands at start. With after:true, the new dimension lands at start+1 (the existing dimension at start is preserved).",
31426
31543
  annotations: { destructiveHint: true },
31427
31544
  inputSchema: {
31428
31545
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
@@ -31430,11 +31547,12 @@ function registerExtraSheetsTools(server2) {
31430
31547
  dimension: external_exports.string().describe("Dimension to insert: ROWS or COLUMNS"),
31431
31548
  start: external_exports.number().describe("Start index (0-based)"),
31432
31549
  count: external_exports.number().optional().describe("Number of rows/columns to insert (default: 1)"),
31433
- after: external_exports.boolean().optional().describe("Insert after the start index instead of before"),
31550
+ after: external_exports.boolean().optional().describe("Insert after the start index instead of before. With after:true the new dimension lands at start+1, leaving the existing dimension at start untouched."),
31434
31551
  account: accountParam
31435
31552
  }
31436
31553
  }, async ({ spreadsheetId, sheet, dimension, start, count, after, account }) => {
31437
- const args = ["sheets", "insert", spreadsheetId, sheet, dimension, String(start)];
31554
+ const effectiveStart = after ? start + 1 : start;
31555
+ const args = ["sheets", "insert", spreadsheetId, sheet, dimension, String(effectiveStart)];
31438
31556
  if (count !== void 0) args.push(`--count=${count}`);
31439
31557
  if (after) args.push("--after");
31440
31558
  return runOrDiagnose(args, { account });
@@ -31553,20 +31671,29 @@ function registerExtraSheetsTools(server2) {
31553
31671
  return runOrDiagnose(args, { account: a.account });
31554
31672
  });
31555
31673
  server2.registerTool("gog_sheets_number_format", {
31556
- description: "Set number format on a range (currency, percentage, date, etc.).",
31674
+ description: "Set number format on a range (currency, percentage, date, etc.). When type is DATE or DATE_TIME, the target range is peeked first; if every numeric cell is a small integer (< 10000), a warning is prepended to the response because Sheets will render those as 1899/1900 day-serials. Pass force:true to skip the check.",
31557
31675
  annotations: { destructiveHint: true },
31558
31676
  inputSchema: {
31559
31677
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
31560
31678
  range: external_exports.string().describe("Range to format (e.g. Sheet1!A1:A10)"),
31561
- type: external_exports.string().optional().describe("Format type: NUMBER, CURRENCY, PERCENT, DATE, TIME, SCIENTIFIC, etc."),
31679
+ type: external_exports.string().optional().describe("Format type: NUMBER, CURRENCY, PERCENT, DATE, DATE_TIME, TIME, SCIENTIFIC, etc."),
31562
31680
  pattern: external_exports.string().optional().describe('Custom format pattern (e.g. "#,##0.00", "yyyy-mm-dd")'),
31681
+ force: external_exports.boolean().optional().describe("Skip the DATE/DATE_TIME small-integer warning check"),
31563
31682
  account: accountParam
31564
31683
  }
31565
- }, async ({ spreadsheetId, range, type, pattern, account }) => {
31684
+ }, async ({ spreadsheetId, range, type, pattern, force, account }) => {
31685
+ const isDateType = type === "DATE" || type === "DATE_TIME";
31686
+ const warning = isDateType && !force ? await checkDateFormatTarget(spreadsheetId, range, account) : null;
31566
31687
  const args = ["sheets", "number-format", spreadsheetId, range];
31567
31688
  if (type) args.push(`--type=${type}`);
31568
31689
  if (pattern) args.push(`--pattern=${pattern}`);
31569
- return runOrDiagnose(args, { account });
31690
+ const result = await runOrDiagnose(args, { account });
31691
+ if (warning) {
31692
+ return { content: [{ type: "text", text: `${warning}
31693
+
31694
+ ${result.content[0].text}` }] };
31695
+ }
31696
+ return result;
31570
31697
  });
31571
31698
  server2.registerTool("gog_sheets_read_format", {
31572
31699
  description: "Read cell formatting for a range.",
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.0.12",
6
+ "version": "2.2.0",
7
7
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -150,7 +150,7 @@
150
150
  },
151
151
  {
152
152
  "name": "gog_sheets_number_format",
153
- "description": "Set number format on a range (currency, percentage, date, etc.)."
153
+ "description": "Set number format on a range (currency, percentage, date, etc.). When type is DATE or DATE_TIME, peeks the target range first and warns if every numeric cell is a small integer (< 10000) — Sheets would render those as 1899/1900 day-serials. Pass force:true to skip the check."
154
154
  },
155
155
  {
156
156
  "name": "gog_sheets_read_format",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-sheets",
3
- "version": "2.0.12",
3
+ "version": "2.2.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.0.12",
10
+ "version": "2.2.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "2.0.12",
15
+ "version": "2.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -18,6 +18,55 @@ export function hexToRgb(hex: string): { red: number; green: number; blue: numbe
18
18
  };
19
19
  }
20
20
 
21
+ // Issue #43: peek the target range before applying DATE / DATE_TIME number
22
+ // formatting. If every numeric cell is a small integer (< 10000), Sheets will
23
+ // render them as day-serials near 1899-12-30 — almost certainly not what the
24
+ // caller intended. Return a warning string in that case, otherwise null.
25
+ //
26
+ // Returns null (no warning) when:
27
+ // - the peek fails / response isn't parseable JSON
28
+ // - the range is empty
29
+ // - any value is a non-integer number or a number >= 10000
30
+ // - any value is a string (other than empty), boolean, etc.
31
+ // Nulls and empty strings are ignored.
32
+ async function checkDateFormatTarget(
33
+ spreadsheetId: string,
34
+ range: string,
35
+ account: string | undefined,
36
+ ): Promise<string | null> {
37
+ const peek = await runOrDiagnose(
38
+ ['sheets', 'get', spreadsheetId, range, '--render=UNFORMATTED_VALUE'],
39
+ { account },
40
+ );
41
+ let parsed: { values?: unknown[][] };
42
+ try {
43
+ parsed = JSON.parse(peek.content[0].text) as { values?: unknown[][] };
44
+ } catch {
45
+ return null;
46
+ }
47
+ const rows = parsed.values;
48
+ if (!Array.isArray(rows) || rows.length === 0) return null;
49
+ let sawSmallInt = false;
50
+ for (const row of rows) {
51
+ for (const cell of row) {
52
+ if (cell === null || cell === undefined || cell === '') continue;
53
+ if (typeof cell !== 'number') return null;
54
+ if (!Number.isInteger(cell)) return null;
55
+ if (cell < 0) return null;
56
+ if (cell >= 10000) return null;
57
+ sawSmallInt = true;
58
+ }
59
+ }
60
+ if (!sawSmallInt) return null;
61
+ return (
62
+ 'Warning: applying DATE/DATE_TIME format to cells holding small integers (< 10000) ' +
63
+ 'will render them as dates near 1899-12-30 because Sheets interprets numeric values as ' +
64
+ 'day-serials from that epoch. If those integers are ordinals (1, 2, 3, ...) and not ' +
65
+ 'day offsets, this is almost certainly not what you want. Pass force:true to suppress ' +
66
+ 'this warning, or convert the cells to real dates / strings first.'
67
+ );
68
+ }
69
+
21
70
  export function registerExtraSheetsTools(server: McpServer): void {
22
71
 
23
72
  server.registerTool('gog_sheets_list_tabs', {
@@ -120,7 +169,7 @@ export function registerExtraSheetsTools(server: McpServer): void {
120
169
  });
121
170
 
122
171
  server.registerTool('gog_sheets_insert', {
123
- description: 'Insert rows or columns into a sheet.',
172
+ description: 'Insert rows or columns into a sheet. With after:false (default), the new dimension lands at start. With after:true, the new dimension lands at start+1 (the existing dimension at start is preserved).',
124
173
  annotations: { destructiveHint: true },
125
174
  inputSchema: {
126
175
  spreadsheetId: z.string().describe('Spreadsheet ID'),
@@ -128,11 +177,16 @@ export function registerExtraSheetsTools(server: McpServer): void {
128
177
  dimension: z.string().describe('Dimension to insert: ROWS or COLUMNS'),
129
178
  start: z.number().describe('Start index (0-based)'),
130
179
  count: z.number().optional().describe('Number of rows/columns to insert (default: 1)'),
131
- after: z.boolean().optional().describe('Insert after the start index instead of before'),
180
+ after: z.boolean().optional().describe('Insert after the start index instead of before. With after:true the new dimension lands at start+1, leaving the existing dimension at start untouched.'),
132
181
  account: accountParam,
133
182
  },
134
183
  }, async ({ spreadsheetId, sheet, dimension, start, count, after, account }) => {
135
- const args = ['sheets', 'insert', spreadsheetId, sheet, dimension, String(start)];
184
+ // The underlying `gog sheets insert` treats `start` as 1-based with `--after` shifting +1.
185
+ // To honor our 0-based `start` and make `after:true` actually shift the insertion to start+1
186
+ // (issue #42), we send `start+1` when `after:true`. With after:false the CLI's `start-1`
187
+ // conversion lands the insertion at our 0-based start.
188
+ const effectiveStart = after ? start + 1 : start;
189
+ const args = ['sheets', 'insert', spreadsheetId, sheet, dimension, String(effectiveStart)];
136
190
  if (count !== undefined) args.push(`--count=${count}`);
137
191
  if (after) args.push('--after');
138
192
  return runOrDiagnose(args, { account });
@@ -255,20 +309,29 @@ export function registerExtraSheetsTools(server: McpServer): void {
255
309
  });
256
310
 
257
311
  server.registerTool('gog_sheets_number_format', {
258
- description: 'Set number format on a range (currency, percentage, date, etc.).',
312
+ description: 'Set number format on a range (currency, percentage, date, etc.). When type is DATE or DATE_TIME, the target range is peeked first; if every numeric cell is a small integer (< 10000), a warning is prepended to the response because Sheets will render those as 1899/1900 day-serials. Pass force:true to skip the check.',
259
313
  annotations: { destructiveHint: true },
260
314
  inputSchema: {
261
315
  spreadsheetId: z.string().describe('Spreadsheet ID'),
262
316
  range: z.string().describe('Range to format (e.g. Sheet1!A1:A10)'),
263
- type: z.string().optional().describe('Format type: NUMBER, CURRENCY, PERCENT, DATE, TIME, SCIENTIFIC, etc.'),
317
+ type: z.string().optional().describe('Format type: NUMBER, CURRENCY, PERCENT, DATE, DATE_TIME, TIME, SCIENTIFIC, etc.'),
264
318
  pattern: z.string().optional().describe('Custom format pattern (e.g. "#,##0.00", "yyyy-mm-dd")'),
319
+ force: z.boolean().optional().describe('Skip the DATE/DATE_TIME small-integer warning check'),
265
320
  account: accountParam,
266
321
  },
267
- }, async ({ spreadsheetId, range, type, pattern, account }) => {
322
+ }, async ({ spreadsheetId, range, type, pattern, force, account }) => {
323
+ const isDateType = type === 'DATE' || type === 'DATE_TIME';
324
+ const warning = isDateType && !force
325
+ ? await checkDateFormatTarget(spreadsheetId, range, account)
326
+ : null;
268
327
  const args = ['sheets', 'number-format', spreadsheetId, range];
269
328
  if (type) args.push(`--type=${type}`);
270
329
  if (pattern) args.push(`--pattern=${pattern}`);
271
- return runOrDiagnose(args, { account });
330
+ const result = await runOrDiagnose(args, { account });
331
+ if (warning) {
332
+ return { content: [{ type: 'text' as const, text: `${warning}\n\n${result.content[0].text}` }] };
333
+ }
334
+ return result;
272
335
  });
273
336
 
274
337
  server.registerTool('gog_sheets_read_format', {
@@ -119,11 +119,21 @@ describe('gog_sheets_insert', () => {
119
119
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'Sheet1', 'ROWS', '5'], { account: undefined });
120
120
  });
121
121
 
122
- it('includes --count and --after when provided', async () => {
122
+ it('shifts start by +1 when after:true so the new dimension lands AFTER start', async () => {
123
+ // Issue #42: with after:true, start=0 means "insert after index 0" — i.e. land at index 1.
124
+ // The MCP must compensate for the CLI's 1-based interpretation by passing start+1.
123
125
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
124
126
  const handlers = setupHandlers();
125
127
  await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'COLUMNS', start: 0, count: 3, after: true });
126
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'COLUMNS', '0', '--count=3', '--after'], { account: undefined });
128
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'COLUMNS', '1', '--count=3', '--after'], { account: undefined });
129
+ });
130
+
131
+ it('issue #42 acceptance: start=28, after:true leaves column 28 untouched (insertion lands at 29)', async () => {
132
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
133
+ const handlers = setupHandlers();
134
+ await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'COLUMNS', start: 28, after: true, count: 1 });
135
+ // CLI is 1-based; with --after CLI uses startIndex = c.Start, so passing 29 → API startIndex=29.
136
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'COLUMNS', '29', '--count=1', '--after'], { account: undefined });
127
137
  });
128
138
 
129
139
  it('includes --count=0 when count is 0', async () => {
@@ -133,7 +143,7 @@ describe('gog_sheets_insert', () => {
133
143
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'insert', 'sid', 'S1', 'ROWS', '0', '--count=0'], { account: undefined });
134
144
  });
135
145
 
136
- it('omits --after when false', async () => {
146
+ it('omits --after and does not shift start when after:false', async () => {
137
147
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
138
148
  const handlers = setupHandlers();
139
149
  await handlers.get('gog_sheets_insert')!({ spreadsheetId: 'sid', sheet: 'S1', dimension: 'ROWS', start: 0, after: false });
@@ -314,6 +324,206 @@ describe('gog_sheets_number_format', () => {
314
324
  await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1', type: 'CURRENCY', pattern: '#,##0.00' });
315
325
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'number-format', 'sid', 'A1', '--type=CURRENCY', '--pattern=#,##0.00'], { account: undefined });
316
326
  });
327
+
328
+ // Issue #43: warn when DATE/DATE_TIME format applied to small integers (silent 1899/1900 render).
329
+ describe('DATE/DATE_TIME small-integer warning', () => {
330
+ it('emits a warning when DATE applied to a range of small integers', async () => {
331
+ const handlers = setupHandlers();
332
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
333
+ if (args[1] === 'get') {
334
+ return toText(JSON.stringify({ range: 'Sheet1!A1:A3', values: [[1], [2], [3]] }));
335
+ }
336
+ return toText('{"ok":true}');
337
+ });
338
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:A3', type: 'DATE' });
339
+ // Peek call goes out first.
340
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
341
+ ['sheets', 'get', 'sid', 'Sheet1!A1:A3', '--render=UNFORMATTED_VALUE'],
342
+ { account: undefined },
343
+ );
344
+ // The format call still happens (warning is non-blocking).
345
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
346
+ ['sheets', 'number-format', 'sid', 'Sheet1!A1:A3', '--type=DATE'],
347
+ { account: undefined },
348
+ );
349
+ const text = result.content[0].text;
350
+ expect(text).toMatch(/warning/i);
351
+ expect(text).toMatch(/1899|1900|day-serial|small integer/i);
352
+ expect(text).toMatch(/force/);
353
+ });
354
+
355
+ it('also warns for DATE_TIME', async () => {
356
+ const handlers = setupHandlers();
357
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
358
+ if (args[1] === 'get') {
359
+ return toText(JSON.stringify({ range: 'Sheet1!A1:A2', values: [[5], [7]] }));
360
+ }
361
+ return toText('{"ok":true}');
362
+ });
363
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:A2', type: 'DATE_TIME' });
364
+ expect(result.content[0].text).toMatch(/warning/i);
365
+ });
366
+
367
+ it('does not peek or warn when force:true', async () => {
368
+ const handlers = setupHandlers();
369
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{"ok":true}'));
370
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:A3', type: 'DATE', force: true });
371
+ // Only the format call should fire — no peek.
372
+ expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
373
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
374
+ ['sheets', 'number-format', 'sid', 'Sheet1!A1:A3', '--type=DATE'],
375
+ { account: undefined },
376
+ );
377
+ expect(result.content[0].text).not.toMatch(/warning/i);
378
+ });
379
+
380
+ it('peeks and warns when force:false is passed explicitly (round-trips the no-force path)', async () => {
381
+ const handlers = setupHandlers();
382
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
383
+ if (args[1] === 'get') {
384
+ return toText(JSON.stringify({ range: 'A1:A3', values: [[1], [2], [3]] }));
385
+ }
386
+ return toText('{"ok":true}');
387
+ });
388
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'DATE', force: false });
389
+ expect(lib.runOrDiagnose).toHaveBeenCalledTimes(2);
390
+ expect(result.content[0].text).toMatch(/warning/i);
391
+ });
392
+
393
+ it('does not peek when type is not DATE/DATE_TIME', async () => {
394
+ const handlers = setupHandlers();
395
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{"ok":true}'));
396
+ await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'CURRENCY' });
397
+ expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
398
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
399
+ ['sheets', 'number-format', 'sid', 'A1:A3', '--type=CURRENCY'],
400
+ { account: undefined },
401
+ );
402
+ });
403
+
404
+ it('does not warn when values include a large integer (>=10000)', async () => {
405
+ const handlers = setupHandlers();
406
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
407
+ if (args[1] === 'get') {
408
+ // 45000 is a real day-serial (year ~2023) — legitimate date value.
409
+ return toText(JSON.stringify({ range: 'A1:A2', values: [[1], [45000]] }));
410
+ }
411
+ return toText('{"ok":true}');
412
+ });
413
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A2', type: 'DATE' });
414
+ expect(result.content[0].text).not.toMatch(/warning/i);
415
+ });
416
+
417
+ it('does not warn when values include a negative integer (deltas/error codes, not day-serials)', async () => {
418
+ const handlers = setupHandlers();
419
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
420
+ if (args[1] === 'get') {
421
+ return toText(JSON.stringify({ range: 'A1:A3', values: [[1], [-5], [3]] }));
422
+ }
423
+ return toText('{"ok":true}');
424
+ });
425
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'DATE' });
426
+ expect(result.content[0].text).not.toMatch(/warning/i);
427
+ });
428
+
429
+ it('does not warn when values include non-integer numbers', async () => {
430
+ const handlers = setupHandlers();
431
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
432
+ if (args[1] === 'get') {
433
+ return toText(JSON.stringify({ range: 'A1:A2', values: [[1], [2.5]] }));
434
+ }
435
+ return toText('{"ok":true}');
436
+ });
437
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A2', type: 'DATE' });
438
+ expect(result.content[0].text).not.toMatch(/warning/i);
439
+ });
440
+
441
+ it('does not warn when range has no values (missing values key)', async () => {
442
+ const handlers = setupHandlers();
443
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
444
+ if (args[1] === 'get') {
445
+ return toText(JSON.stringify({ range: 'A1:A3' }));
446
+ }
447
+ return toText('{"ok":true}');
448
+ });
449
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'DATE' });
450
+ expect(result.content[0].text).not.toMatch(/warning/i);
451
+ });
452
+
453
+ it('does not warn when range has empty values array', async () => {
454
+ const handlers = setupHandlers();
455
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
456
+ if (args[1] === 'get') {
457
+ return toText(JSON.stringify({ range: 'A1:A3', values: [] }));
458
+ }
459
+ return toText('{"ok":true}');
460
+ });
461
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'DATE' });
462
+ expect(result.content[0].text).not.toMatch(/warning/i);
463
+ });
464
+
465
+ it('does not warn when values are strings', async () => {
466
+ const handlers = setupHandlers();
467
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
468
+ if (args[1] === 'get') {
469
+ return toText(JSON.stringify({ range: 'A1:A2', values: [['hello'], ['world']] }));
470
+ }
471
+ return toText('{"ok":true}');
472
+ });
473
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A2', type: 'DATE' });
474
+ expect(result.content[0].text).not.toMatch(/warning/i);
475
+ });
476
+
477
+ it('ignores null and empty cells when deciding to warn', async () => {
478
+ const handlers = setupHandlers();
479
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
480
+ if (args[1] === 'get') {
481
+ // Real-world: nulls/empty strings interspersed with the ordinal ints.
482
+ return toText(JSON.stringify({ range: 'A1:A4', values: [[1], [null], [''], [3]] }));
483
+ }
484
+ return toText('{"ok":true}');
485
+ });
486
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A4', type: 'DATE' });
487
+ expect(result.content[0].text).toMatch(/warning/i);
488
+ });
489
+
490
+ it('proceeds with format when peek fails (does not block)', async () => {
491
+ const handlers = setupHandlers();
492
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
493
+ if (args[1] === 'get') {
494
+ return toText('Error: something went wrong');
495
+ }
496
+ return toText('{"ok":true}');
497
+ });
498
+ const result = await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1:A3', type: 'DATE' });
499
+ // Format call should still happen.
500
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
501
+ ['sheets', 'number-format', 'sid', 'A1:A3', '--type=DATE'],
502
+ { account: undefined },
503
+ );
504
+ // No warning emitted when peek didn't yield clear evidence.
505
+ expect(result.content[0].text).not.toMatch(/warning/i);
506
+ });
507
+
508
+ it('forwards account on both peek and format calls', async () => {
509
+ const handlers = setupHandlers();
510
+ vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: string[]) => {
511
+ if (args[1] === 'get') {
512
+ return toText(JSON.stringify({ range: 'A1', values: [[1]] }));
513
+ }
514
+ return toText('{}');
515
+ });
516
+ await handlers.get('gog_sheets_number_format')!({ spreadsheetId: 'sid', range: 'A1', type: 'DATE', account: 'a@b.com' });
517
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
518
+ ['sheets', 'get', 'sid', 'A1', '--render=UNFORMATTED_VALUE'],
519
+ { account: 'a@b.com' },
520
+ );
521
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
522
+ ['sheets', 'number-format', 'sid', 'A1', '--type=DATE'],
523
+ { account: 'a@b.com' },
524
+ );
525
+ });
526
+ });
317
527
  });
318
528
 
319
529
  // 12. read-format