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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +81 -0
- package/dist/index.js +163 -36
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/tools/sheets-extra.ts +70 -7
- package/tests/tools/sheets-extra.test.ts +213 -3
|
@@ -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
|
|
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
|
|
18
|
+
"version": "2.2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31246
|
-
|
|
31247
|
-
|
|
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
|
-
|
|
31261
|
-
|
|
31262
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
10
|
+
"version": "2.2.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-sheets",
|
|
15
|
-
"version": "2.0
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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', '
|
|
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
|