gogcli-mcp-sheets 2.0.8 → 2.0.10

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.8"
10
+ "version": "2.0.10"
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.8",
18
+ "version": "2.0.10",
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.8",
4
+ "version": "2.0.10",
5
5
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/index.js CHANGED
@@ -3105,6 +3105,9 @@ var require_utils = __commonJS({
3105
3105
  "use strict";
3106
3106
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3107
3107
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
3108
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
3109
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
3110
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
3108
3111
  function stringArrayToHexStripped(input) {
3109
3112
  let acc = "";
3110
3113
  let code = 0;
@@ -3297,27 +3300,77 @@ var require_utils = __commonJS({
3297
3300
  }
3298
3301
  return output.join("");
3299
3302
  }
3300
- function normalizeComponentEncoding(component, esc2) {
3301
- const func = esc2 !== true ? escape : unescape;
3302
- if (component.scheme !== void 0) {
3303
- component.scheme = func(component.scheme);
3304
- }
3305
- if (component.userinfo !== void 0) {
3306
- component.userinfo = func(component.userinfo);
3307
- }
3308
- if (component.host !== void 0) {
3309
- component.host = func(component.host);
3303
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3304
+ var HOST_DELIM_RE = /[@/?#:]/g;
3305
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3306
+ function reescapeHostDelimiters(host, isIP) {
3307
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3308
+ re.lastIndex = 0;
3309
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
3310
+ }
3311
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
3312
+ if (input.indexOf("%") === -1) {
3313
+ return input;
3310
3314
  }
3311
- if (component.path !== void 0) {
3312
- component.path = func(component.path);
3315
+ let output = "";
3316
+ for (let i = 0; i < input.length; i++) {
3317
+ if (input[i] === "%" && i + 2 < input.length) {
3318
+ const hex3 = input.slice(i + 1, i + 3);
3319
+ if (isHexPair(hex3)) {
3320
+ const normalizedHex = hex3.toUpperCase();
3321
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3322
+ if (decodeUnreserved && isUnreserved(decoded)) {
3323
+ output += decoded;
3324
+ } else {
3325
+ output += "%" + normalizedHex;
3326
+ }
3327
+ i += 2;
3328
+ continue;
3329
+ }
3330
+ }
3331
+ output += input[i];
3313
3332
  }
3314
- if (component.query !== void 0) {
3315
- component.query = func(component.query);
3333
+ return output;
3334
+ }
3335
+ function normalizePathEncoding(input) {
3336
+ let output = "";
3337
+ for (let i = 0; i < input.length; i++) {
3338
+ if (input[i] === "%" && i + 2 < input.length) {
3339
+ const hex3 = input.slice(i + 1, i + 3);
3340
+ if (isHexPair(hex3)) {
3341
+ const normalizedHex = hex3.toUpperCase();
3342
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3343
+ if (decoded !== "." && isUnreserved(decoded)) {
3344
+ output += decoded;
3345
+ } else {
3346
+ output += "%" + normalizedHex;
3347
+ }
3348
+ i += 2;
3349
+ continue;
3350
+ }
3351
+ }
3352
+ if (isPathCharacter(input[i])) {
3353
+ output += input[i];
3354
+ } else {
3355
+ output += escape(input[i]);
3356
+ }
3316
3357
  }
3317
- if (component.fragment !== void 0) {
3318
- component.fragment = func(component.fragment);
3358
+ return output;
3359
+ }
3360
+ function escapePreservingEscapes(input) {
3361
+ let output = "";
3362
+ for (let i = 0; i < input.length; i++) {
3363
+ if (input[i] === "%" && i + 2 < input.length) {
3364
+ const hex3 = input.slice(i + 1, i + 3);
3365
+ if (isHexPair(hex3)) {
3366
+ output += "%" + hex3.toUpperCase();
3367
+ i += 2;
3368
+ continue;
3369
+ }
3370
+ }
3371
+ output += escape(input[i]);
3319
3372
  }
3320
- return component;
3373
+ return output;
3321
3374
  }
3322
3375
  function recomposeAuthority(component) {
3323
3376
  const uriTokens = [];
@@ -3332,7 +3385,7 @@ var require_utils = __commonJS({
3332
3385
  if (ipV6res.isIPV6 === true) {
3333
3386
  host = `[${ipV6res.escapedHost}]`;
3334
3387
  } else {
3335
- host = component.host;
3388
+ host = reescapeHostDelimiters(host, false);
3336
3389
  }
3337
3390
  }
3338
3391
  uriTokens.push(host);
@@ -3346,7 +3399,10 @@ var require_utils = __commonJS({
3346
3399
  module.exports = {
3347
3400
  nonSimpleDomain,
3348
3401
  recomposeAuthority,
3349
- normalizeComponentEncoding,
3402
+ reescapeHostDelimiters,
3403
+ normalizePercentEncoding,
3404
+ normalizePathEncoding,
3405
+ escapePreservingEscapes,
3350
3406
  removeDotSegments,
3351
3407
  isIPv4,
3352
3408
  isUUID,
@@ -3570,12 +3626,12 @@ var require_schemes = __commonJS({
3570
3626
  var require_fast_uri = __commonJS({
3571
3627
  "../../node_modules/fast-uri/index.js"(exports, module) {
3572
3628
  "use strict";
3573
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3629
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3574
3630
  var { SCHEMES, getSchemeHandler } = require_schemes();
3575
3631
  function normalize(uri, options) {
3576
3632
  if (typeof uri === "string") {
3577
3633
  uri = /** @type {T} */
3578
- serialize(parse3(uri, options), options);
3634
+ normalizeString(uri, options);
3579
3635
  } else if (typeof uri === "object") {
3580
3636
  uri = /** @type {T} */
3581
3637
  parse3(serialize(uri, options), options);
@@ -3642,19 +3698,9 @@ var require_fast_uri = __commonJS({
3642
3698
  return target;
3643
3699
  }
3644
3700
  function equal(uriA, uriB, options) {
3645
- if (typeof uriA === "string") {
3646
- uriA = unescape(uriA);
3647
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
3648
- } else if (typeof uriA === "object") {
3649
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
3650
- }
3651
- if (typeof uriB === "string") {
3652
- uriB = unescape(uriB);
3653
- uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
3654
- } else if (typeof uriB === "object") {
3655
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
3656
- }
3657
- return uriA.toLowerCase() === uriB.toLowerCase();
3701
+ const normalizedA = normalizeComparableURI(uriA, options);
3702
+ const normalizedB = normalizeComparableURI(uriB, options);
3703
+ return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3658
3704
  }
3659
3705
  function serialize(cmpts, opts) {
3660
3706
  const component = {
@@ -3679,12 +3725,12 @@ var require_fast_uri = __commonJS({
3679
3725
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
3680
3726
  if (component.path !== void 0) {
3681
3727
  if (!options.skipEscape) {
3682
- component.path = escape(component.path);
3728
+ component.path = escapePreservingEscapes(component.path);
3683
3729
  if (component.scheme !== void 0) {
3684
3730
  component.path = component.path.split("%3A").join(":");
3685
3731
  }
3686
3732
  } else {
3687
- component.path = unescape(component.path);
3733
+ component.path = normalizePercentEncoding(component.path);
3688
3734
  }
3689
3735
  }
3690
3736
  if (options.reference !== "suffix" && component.scheme) {
@@ -3719,7 +3765,16 @@ var require_fast_uri = __commonJS({
3719
3765
  return uriTokens.join("");
3720
3766
  }
3721
3767
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3722
- function parse3(uri, opts) {
3768
+ function getParseError(parsed, matches) {
3769
+ if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3770
+ return 'URI path must start with "/" when authority is present.';
3771
+ }
3772
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
3773
+ return "URI port is malformed.";
3774
+ }
3775
+ return void 0;
3776
+ }
3777
+ function parseWithStatus(uri, opts) {
3723
3778
  const options = Object.assign({}, opts);
3724
3779
  const parsed = {
3725
3780
  scheme: void 0,
@@ -3730,6 +3785,7 @@ var require_fast_uri = __commonJS({
3730
3785
  query: void 0,
3731
3786
  fragment: void 0
3732
3787
  };
3788
+ let malformedAuthorityOrPort = false;
3733
3789
  let isIP = false;
3734
3790
  if (options.reference === "suffix") {
3735
3791
  if (options.scheme) {
@@ -3750,6 +3806,11 @@ var require_fast_uri = __commonJS({
3750
3806
  if (isNaN(parsed.port)) {
3751
3807
  parsed.port = matches[5];
3752
3808
  }
3809
+ const parseError = getParseError(parsed, matches);
3810
+ if (parseError !== void 0) {
3811
+ parsed.error = parsed.error || parseError;
3812
+ malformedAuthorityOrPort = true;
3813
+ }
3753
3814
  if (parsed.host) {
3754
3815
  const ipv4result = isIPv4(parsed.host);
3755
3816
  if (ipv4result === false) {
@@ -3788,14 +3849,18 @@ var require_fast_uri = __commonJS({
3788
3849
  parsed.scheme = unescape(parsed.scheme);
3789
3850
  }
3790
3851
  if (parsed.host !== void 0) {
3791
- parsed.host = unescape(parsed.host);
3852
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3792
3853
  }
3793
3854
  }
3794
3855
  if (parsed.path) {
3795
- parsed.path = escape(unescape(parsed.path));
3856
+ parsed.path = normalizePathEncoding(parsed.path);
3796
3857
  }
3797
3858
  if (parsed.fragment) {
3798
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3859
+ try {
3860
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3861
+ } catch {
3862
+ parsed.error = parsed.error || "URI malformed";
3863
+ }
3799
3864
  }
3800
3865
  }
3801
3866
  if (schemeHandler && schemeHandler.parse) {
@@ -3804,7 +3869,29 @@ var require_fast_uri = __commonJS({
3804
3869
  } else {
3805
3870
  parsed.error = parsed.error || "URI can not be parsed.";
3806
3871
  }
3807
- return parsed;
3872
+ return { parsed, malformedAuthorityOrPort };
3873
+ }
3874
+ function parse3(uri, opts) {
3875
+ return parseWithStatus(uri, opts).parsed;
3876
+ }
3877
+ function normalizeString(uri, opts) {
3878
+ return normalizeStringWithStatus(uri, opts).normalized;
3879
+ }
3880
+ function normalizeStringWithStatus(uri, opts) {
3881
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3882
+ return {
3883
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
3884
+ malformedAuthorityOrPort
3885
+ };
3886
+ }
3887
+ function normalizeComparableURI(uri, opts) {
3888
+ if (typeof uri === "string") {
3889
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
3890
+ return malformedAuthorityOrPort ? void 0 : normalized;
3891
+ }
3892
+ if (typeof uri === "object") {
3893
+ return serialize(uri, opts);
3894
+ }
3808
3895
  }
3809
3896
  var fastUri = {
3810
3897
  SCHEMES,
@@ -9886,14 +9973,14 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
9886
9973
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
9887
9974
  };
9888
9975
  doc.write(`const input = payload.value;`);
9889
- const ids = /* @__PURE__ */ Object.create(null);
9976
+ const ids2 = /* @__PURE__ */ Object.create(null);
9890
9977
  let counter = 0;
9891
9978
  for (const key of normalized.keys) {
9892
- ids[key] = `key_${counter++}`;
9979
+ ids2[key] = `key_${counter++}`;
9893
9980
  }
9894
9981
  doc.write(`const newResult = {};`);
9895
9982
  for (const key of normalized.keys) {
9896
- const id = ids[key];
9983
+ const id = ids2[key];
9897
9984
  const k = esc(key);
9898
9985
  const schema = shape[key];
9899
9986
  const isOptionalIn = schema?._zod?.optin === "optional";
@@ -30870,6 +30957,32 @@ function envOrUndefined(key) {
30870
30957
  if (!value || value.startsWith("${")) return void 0;
30871
30958
  return value;
30872
30959
  }
30960
+ function sanitizedEnv() {
30961
+ const result = {};
30962
+ for (const [key, value] of Object.entries(process.env)) {
30963
+ if (key === "GOG_ACCESS_TOKEN") continue;
30964
+ if (key === "GOOGLE_APPLICATION_CREDENTIALS") continue;
30965
+ if (/(_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY)$/.test(key)) continue;
30966
+ result[key] = value;
30967
+ }
30968
+ return result;
30969
+ }
30970
+ var TOKEN_PATTERNS = [
30971
+ /Bearer\s+[A-Za-z0-9._\-+/=]+/gi,
30972
+ /ya29\.[A-Za-z0-9._\-]+/g,
30973
+ // OAuth2 access tokens
30974
+ /1\/\/[A-Za-z0-9._\-]+/g,
30975
+ // OAuth2 refresh tokens
30976
+ /AIza[A-Za-z0-9_\-]{35}/g
30977
+ // Google API keys
30978
+ ];
30979
+ function redactSecrets(text) {
30980
+ let redacted = text;
30981
+ for (const re of TOKEN_PATTERNS) {
30982
+ redacted = redacted.replace(re, "[REDACTED]");
30983
+ }
30984
+ return redacted;
30985
+ }
30873
30986
  function augmentedPath() {
30874
30987
  const home = process.env.HOME;
30875
30988
  const candidates = [
@@ -30912,8 +31025,7 @@ async function run(args, options = {}) {
30912
31025
  fullArgs.push(...args);
30913
31026
  const effectiveTimeout = timeout ?? TIMEOUT_MS;
30914
31027
  return new Promise((resolve, reject) => {
30915
- const { GOG_ACCESS_TOKEN: _, ...cleanEnv } = process.env;
30916
- const childEnv = { ...cleanEnv, PATH: augmentedPath() };
31028
+ const childEnv = { ...sanitizedEnv(), PATH: augmentedPath() };
30917
31029
  const child = spawner(envOrUndefined("GOG_PATH") ?? "gog", fullArgs, { env: childEnv });
30918
31030
  const stdoutChunks = [];
30919
31031
  const stderrChunks = [];
@@ -30942,7 +31054,7 @@ async function run(args, options = {}) {
30942
31054
  resolve(stdout);
30943
31055
  }
30944
31056
  } else {
30945
- reject(new Error(stderr || `gog exited with code ${code}`));
31057
+ reject(new Error(redactSecrets(stderr || `gog exited with code ${code}`)));
30946
31058
  }
30947
31059
  });
30948
31060
  child.on("error", (err) => {
@@ -30964,6 +31076,55 @@ async function run(args, options = {}) {
30964
31076
  var accountParam = external_exports.string().optional().describe(
30965
31077
  "Google account email to use (overrides GOG_ACCOUNT env var)"
30966
31078
  );
31079
+ var ids = {
31080
+ course: external_exports.string().describe("Course ID"),
31081
+ coursework: external_exports.string().describe("Coursework ID"),
31082
+ submission: external_exports.string().describe("Submission ID"),
31083
+ announcement: external_exports.string().describe("Announcement ID"),
31084
+ topic: external_exports.string().describe("Topic ID"),
31085
+ invitation: external_exports.string().describe("Invitation ID"),
31086
+ spreadsheet: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31087
+ doc: external_exports.string().describe("Doc ID (from the URL)"),
31088
+ presentation: external_exports.string().describe("Presentation ID"),
31089
+ slide: external_exports.string().describe("Slide ID"),
31090
+ file: external_exports.string().describe("File ID"),
31091
+ message: external_exports.string().describe("Message ID"),
31092
+ thread: external_exports.string().describe("Thread ID"),
31093
+ draft: external_exports.string().describe("Draft ID"),
31094
+ label: external_exports.string().describe("Label ID or name"),
31095
+ attachment: external_exports.string().describe("Attachment ID"),
31096
+ comment: external_exports.string().describe("Comment ID"),
31097
+ meetingCode: external_exports.string().describe("Meeting code (e.g. abc-defg-hij)"),
31098
+ permission: external_exports.string().describe("Permission ID"),
31099
+ user: external_exports.string().describe("User ID"),
31100
+ // People API uses fully-qualified resource names ("people/c123") not bare IDs.
31101
+ person: external_exports.string().describe("Person resource name (people/...) or email")
31102
+ };
31103
+ var paginationParams = {
31104
+ max: external_exports.number().int().optional().describe("Max results"),
31105
+ page: external_exports.string().optional().describe("Page token"),
31106
+ all: external_exports.boolean().optional().describe("Fetch all pages")
31107
+ };
31108
+ function registerRunTool(server2, options) {
31109
+ const { service, examples, omitAccount = false, note } = options;
31110
+ const baseDescription = `Run any gog ${service} subcommand not covered by the other tools. Run \`gog ${service} --help\` for the full list of subcommands, or \`gog ${service} <subcommand> --help\` for flags on a specific subcommand.`;
31111
+ const description = note ? `${baseDescription} ${note}` : baseDescription;
31112
+ const inputSchema = {
31113
+ subcommand: external_exports.string().describe(`The gog ${service} subcommand to run, e.g. ${examples}`),
31114
+ args: external_exports.array(external_exports.string()).describe("Additional positional args and flags")
31115
+ };
31116
+ if (!omitAccount) {
31117
+ inputSchema.account = accountParam;
31118
+ }
31119
+ server2.registerTool(`gog_${service}_run`, {
31120
+ description,
31121
+ annotations: { destructiveHint: true },
31122
+ inputSchema
31123
+ }, async (rawArgs) => {
31124
+ const { subcommand, args, account } = rawArgs;
31125
+ return runOrDiagnose([service, subcommand, ...args], { account });
31126
+ });
31127
+ }
30967
31128
  function toText(output) {
30968
31129
  return { content: [{ type: "text", text: output }] };
30969
31130
  }
@@ -31049,19 +31210,16 @@ function registerAuthTools(server2) {
31049
31210
  return toError(err);
31050
31211
  }
31051
31212
  });
31052
- server2.registerTool("gog_auth_run", {
31053
- description: "Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: for browser-based authorization, use gog_auth_add instead.",
31054
- annotations: { destructiveHint: true },
31055
- inputSchema: {
31056
- subcommand: external_exports.string().describe('The gog auth subcommand, e.g. "remove", "alias", "tokens"'),
31057
- args: external_exports.array(external_exports.string()).describe("Additional positional args and flags")
31058
- }
31059
- }, async ({ subcommand, args }) => {
31060
- return runOrDiagnose(["auth", subcommand, ...args], {});
31213
+ registerRunTool(server2, {
31214
+ service: "auth",
31215
+ examples: '"remove", "alias", "tokens"',
31216
+ omitAccount: true,
31217
+ note: "For browser-based authorization, use gog_auth_add instead."
31061
31218
  });
31062
31219
  }
31063
31220
 
31064
31221
  // ../gogcli-mcp/src/tools/sheets.ts
31222
+ var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
31065
31223
  function registerSheetsTools(server2) {
31066
31224
  server2.registerTool("gog_sheets_get", {
31067
31225
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -31075,12 +31233,12 @@ function registerSheetsTools(server2) {
31075
31233
  return runOrDiagnose(["sheets", "get", spreadsheetId, range], { account });
31076
31234
  });
31077
31235
  server2.registerTool("gog_sheets_update", {
31078
- description: "Write values to a Google Sheets range, overwriting existing content.",
31236
+ description: 'Write values to a Google Sheets range, overwriting existing content. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas (e.g. "=SUM(A1:A10)").',
31079
31237
  annotations: { destructiveHint: true },
31080
31238
  inputSchema: {
31081
31239
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31082
31240
  range: external_exports.string().describe("Top-left cell or range in A1 notation, e.g. Sheet1!A1"),
31083
- values: external_exports.array(external_exports.array(external_exports.string())).describe("2D array of values: outer array is rows, inner is columns"),
31241
+ 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.'),
31084
31242
  account: accountParam
31085
31243
  }
31086
31244
  }, async ({ spreadsheetId, range, values, account }) => {
@@ -31090,12 +31248,12 @@ function registerSheetsTools(server2) {
31090
31248
  );
31091
31249
  });
31092
31250
  server2.registerTool("gog_sheets_append", {
31093
- description: "Append rows to a Google Sheet after the last row with data in the given range.",
31251
+ 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.',
31094
31252
  annotations: { destructiveHint: true },
31095
31253
  inputSchema: {
31096
31254
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31097
31255
  range: external_exports.string().describe("Range indicating which sheet/columns to append to, e.g. Sheet1!A:C"),
31098
- values: external_exports.array(external_exports.array(external_exports.string())).describe("2D array of rows to append"),
31256
+ 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.'),
31099
31257
  account: accountParam
31100
31258
  }
31101
31259
  }, async ({ spreadsheetId, range, values, account }) => {
@@ -31147,21 +31305,11 @@ function registerSheetsTools(server2) {
31147
31305
  }, async ({ spreadsheetId, find, replace, account }) => {
31148
31306
  return runOrDiagnose(["sheets", "find-replace", spreadsheetId, find, replace], { account });
31149
31307
  });
31150
- server2.registerTool("gog_sheets_run", {
31151
- description: "Run any gog sheets subcommand not covered by the other tools. Run `gog sheets --help` for the full list of subcommands, or `gog sheets <subcommand> --help` for flags on a specific subcommand.",
31152
- annotations: { destructiveHint: true },
31153
- inputSchema: {
31154
- subcommand: external_exports.string().describe('The gog sheets subcommand to run, e.g. "freeze", "add-tab", "rename-tab"'),
31155
- args: external_exports.array(external_exports.string()).describe('Additional positional args and flags, e.g. ["<spreadsheetId>", "--rows=1"]'),
31156
- account: accountParam
31157
- }
31158
- }, async ({ subcommand, args, account }) => {
31159
- return runOrDiagnose(["sheets", subcommand, ...args], { account });
31160
- });
31308
+ registerRunTool(server2, { service: "sheets", examples: '"freeze", "add-tab", "rename-tab"' });
31161
31309
  }
31162
31310
 
31163
31311
  // ../gogcli-mcp/src/server.ts
31164
- var VERSION = true ? "2.0.8" : "0.0.0";
31312
+ var VERSION = true ? "2.0.10" : "0.0.0";
31165
31313
  function createServer(options) {
31166
31314
  return new McpServer({
31167
31315
  name: options?.name ?? "gogcli",
@@ -31170,7 +31318,31 @@ function createServer(options) {
31170
31318
  }
31171
31319
 
31172
31320
  // src/tools/sheets-extra.ts
31321
+ function hexToRgb(hex3) {
31322
+ let h = hex3.trim().replace(/^#/, "");
31323
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
31324
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
31325
+ const n = parseInt(h, 16);
31326
+ return {
31327
+ red: (n >> 16 & 255) / 255,
31328
+ green: (n >> 8 & 255) / 255,
31329
+ blue: (n & 255) / 255
31330
+ };
31331
+ }
31173
31332
  function registerExtraSheetsTools(server2) {
31333
+ server2.registerTool("gog_sheets_list_tabs", {
31334
+ 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.",
31335
+ annotations: { readOnlyHint: true },
31336
+ inputSchema: {
31337
+ spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31338
+ account: accountParam
31339
+ }
31340
+ }, async ({ spreadsheetId, account }) => {
31341
+ return runOrDiagnose(
31342
+ ["sheets", "metadata", spreadsheetId, "--select=sheets.properties.sheetId,sheets.properties.title,sheets.properties.index,sheets.properties.gridProperties"],
31343
+ { account }
31344
+ );
31345
+ });
31174
31346
  server2.registerTool("gog_sheets_add_tab", {
31175
31347
  description: "Add a new sheet tab to a spreadsheet.",
31176
31348
  inputSchema: {
@@ -31293,19 +31465,92 @@ function registerExtraSheetsTools(server2) {
31293
31465
  return runOrDiagnose(["sheets", "unmerge", spreadsheetId, range], { account });
31294
31466
  });
31295
31467
  server2.registerTool("gog_sheets_format", {
31296
- description: "Apply cell formatting (bold, colors, alignment, etc.) to a range. Pass format as a JSON CellFormat object.",
31468
+ description: "Apply cell formatting to a range. The named flags (bold, italic, backgroundColor, etc.) compose into a Sheets API CellFormat \u2014 use them for the 90% case. For full API control, pass formatJson + formatFields (Sheets CellFormat + field mask) directly.",
31297
31469
  annotations: { destructiveHint: true },
31298
31470
  inputSchema: {
31299
31471
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
31300
31472
  range: external_exports.string().describe("Range to format (e.g. Sheet1!A1:C3)"),
31301
- formatJson: external_exports.string().describe('JSON CellFormat object (e.g. {"textFormat":{"bold":true}})'),
31302
- formatFields: external_exports.string().optional().describe("Comma-separated field mask (e.g. textFormat.bold,backgroundColor)"),
31473
+ bold: external_exports.boolean().optional().describe("Set bold"),
31474
+ italic: external_exports.boolean().optional().describe("Set italic"),
31475
+ underline: external_exports.boolean().optional().describe("Set underline"),
31476
+ strikethrough: external_exports.boolean().optional().describe("Set strikethrough"),
31477
+ fontSize: external_exports.number().optional().describe("Font size in points"),
31478
+ fontFamily: external_exports.string().optional().describe("Font family (e.g. Arial)"),
31479
+ textColor: external_exports.string().optional().describe("Text color as #RRGGBB or #RGB"),
31480
+ backgroundColor: external_exports.string().optional().describe("Cell background color as #RRGGBB or #RGB"),
31481
+ horizontalAlignment: external_exports.enum(["LEFT", "CENTER", "RIGHT"]).optional().describe("Horizontal alignment"),
31482
+ verticalAlignment: external_exports.enum(["TOP", "MIDDLE", "BOTTOM"]).optional().describe("Vertical alignment"),
31483
+ wrapStrategy: external_exports.enum(["OVERFLOW_CELL", "LEGACY_WRAP", "CLIP", "WRAP"]).optional().describe("Text wrap strategy"),
31484
+ formatJson: external_exports.string().optional().describe("Escape hatch: raw CellFormat JSON. When provided, named flags above are ignored and formatJson is sent as-is."),
31485
+ formatFields: external_exports.string().optional().describe("Comma-separated field mask (e.g. textFormat.bold,backgroundColor). Required when using formatJson; auto-computed when using named flags."),
31303
31486
  account: accountParam
31304
31487
  }
31305
- }, async ({ spreadsheetId, range, formatJson, formatFields, account }) => {
31306
- const args = ["sheets", "format", spreadsheetId, range, `--format-json=${formatJson}`];
31488
+ }, async (rawArgs) => {
31489
+ const a = rawArgs;
31490
+ let formatJson = a.formatJson;
31491
+ let formatFields = a.formatFields;
31492
+ if (!formatJson) {
31493
+ const cellFormat = {};
31494
+ const textFormat = {};
31495
+ const fields = [];
31496
+ if (a.bold !== void 0) {
31497
+ textFormat.bold = a.bold;
31498
+ fields.push("textFormat.bold");
31499
+ }
31500
+ if (a.italic !== void 0) {
31501
+ textFormat.italic = a.italic;
31502
+ fields.push("textFormat.italic");
31503
+ }
31504
+ if (a.underline !== void 0) {
31505
+ textFormat.underline = a.underline;
31506
+ fields.push("textFormat.underline");
31507
+ }
31508
+ if (a.strikethrough !== void 0) {
31509
+ textFormat.strikethrough = a.strikethrough;
31510
+ fields.push("textFormat.strikethrough");
31511
+ }
31512
+ if (a.fontSize !== void 0) {
31513
+ textFormat.fontSize = a.fontSize;
31514
+ fields.push("textFormat.fontSize");
31515
+ }
31516
+ if (a.fontFamily !== void 0) {
31517
+ textFormat.fontFamily = a.fontFamily;
31518
+ fields.push("textFormat.fontFamily");
31519
+ }
31520
+ if (a.textColor !== void 0) {
31521
+ const rgb = hexToRgb(a.textColor);
31522
+ if (!rgb) throw new Error(`Invalid textColor: ${a.textColor} (expected #RRGGBB or #RGB)`);
31523
+ textFormat.foregroundColor = rgb;
31524
+ fields.push("textFormat.foregroundColor");
31525
+ }
31526
+ if (Object.keys(textFormat).length > 0) cellFormat.textFormat = textFormat;
31527
+ if (a.backgroundColor !== void 0) {
31528
+ const rgb = hexToRgb(a.backgroundColor);
31529
+ if (!rgb) throw new Error(`Invalid backgroundColor: ${a.backgroundColor} (expected #RRGGBB or #RGB)`);
31530
+ cellFormat.backgroundColor = rgb;
31531
+ fields.push("backgroundColor");
31532
+ }
31533
+ if (a.horizontalAlignment !== void 0) {
31534
+ cellFormat.horizontalAlignment = a.horizontalAlignment;
31535
+ fields.push("horizontalAlignment");
31536
+ }
31537
+ if (a.verticalAlignment !== void 0) {
31538
+ cellFormat.verticalAlignment = a.verticalAlignment;
31539
+ fields.push("verticalAlignment");
31540
+ }
31541
+ if (a.wrapStrategy !== void 0) {
31542
+ cellFormat.wrapStrategy = a.wrapStrategy;
31543
+ fields.push("wrapStrategy");
31544
+ }
31545
+ if (Object.keys(cellFormat).length === 0) {
31546
+ throw new Error("gog_sheets_format requires at least one named flag or a formatJson value");
31547
+ }
31548
+ formatJson = JSON.stringify(cellFormat);
31549
+ if (!formatFields) formatFields = fields.join(",");
31550
+ }
31551
+ const args = ["sheets", "format", a.spreadsheetId, a.range, `--format-json=${formatJson}`];
31307
31552
  if (formatFields) args.push(`--format-fields=${formatFields}`);
31308
- return runOrDiagnose(args, { account });
31553
+ return runOrDiagnose(args, { account: a.account });
31309
31554
  });
31310
31555
  server2.registerTool("gog_sheets_number_format", {
31311
31556
  description: "Set number format on a range (currency, percentage, date, etc.).",
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.8",
6
+ "version": "2.0.10",
7
7
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -146,7 +146,7 @@
146
146
  },
147
147
  {
148
148
  "name": "gog_sheets_format",
149
- "description": "Apply cell formatting (bold, colors, alignment, etc.) to a range. Pass format as a JSON CellFormat object."
149
+ "description": "Apply cell formatting to a range. Named flags (bold, italic, backgroundColor, etc.) compose to a Sheets CellFormat; pass formatJson for full control."
150
150
  },
151
151
  {
152
152
  "name": "gog_sheets_number_format",
@@ -195,6 +195,10 @@
195
195
  {
196
196
  "name": "gog_sheets_named_ranges_delete",
197
197
  "description": "Delete a named range."
198
+ },
199
+ {
200
+ "name": "gog_sheets_list_tabs",
201
+ "description": "List tabs in a spreadsheet (sheetId, title, index, gridProperties)"
198
202
  }
199
203
  ],
200
204
  "compatibility": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-sheets",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
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.8",
10
+ "version": "2.0.10",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "2.0.8",
15
+ "version": "2.0.10",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -2,8 +2,39 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose } from '../../../gogcli-mcp/src/lib.js';
4
4
 
5
+ // Convert a CSS-style hex color ("#FFF5D9", "#FD9", "FFF5D9") to the
6
+ // {red, green, blue} 0-1 float triple that Sheets API CellFormat expects.
7
+ // Returns null on unparseable input — caller decides whether to fall back
8
+ // or error.
9
+ export function hexToRgb(hex: string): { red: number; green: number; blue: number } | null {
10
+ let h = hex.trim().replace(/^#/, '');
11
+ if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
12
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
13
+ const n = parseInt(h, 16);
14
+ return {
15
+ red: ((n >> 16) & 0xff) / 255,
16
+ green: ((n >> 8) & 0xff) / 255,
17
+ blue: (n & 0xff) / 255,
18
+ };
19
+ }
20
+
5
21
  export function registerExtraSheetsTools(server: McpServer): void {
6
- // 1. Add tab
22
+
23
+ server.registerTool('gog_sheets_list_tabs', {
24
+ 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 — useful for restructuring a workbook over a long agent session without losing track of names.',
25
+ annotations: { readOnlyHint: true },
26
+ inputSchema: {
27
+ spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
28
+ account: accountParam,
29
+ },
30
+ }, async ({ spreadsheetId, account }) => {
31
+ // jq projection keeps the response compact: sheetId, title, index, gridProperties only.
32
+ return runOrDiagnose(
33
+ ['sheets', 'metadata', spreadsheetId, '--select=sheets.properties.sheetId,sheets.properties.title,sheets.properties.index,sheets.properties.gridProperties'],
34
+ { account },
35
+ );
36
+ });
37
+
7
38
  server.registerTool('gog_sheets_add_tab', {
8
39
  description: 'Add a new sheet tab to a spreadsheet.',
9
40
  inputSchema: {
@@ -15,7 +46,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
15
46
  return runOrDiagnose(['sheets', 'add-tab', spreadsheetId, tabName], { account });
16
47
  });
17
48
 
18
- // 2. Delete tab
19
49
  server.registerTool('gog_sheets_delete_tab', {
20
50
  description: 'Delete a sheet tab from a spreadsheet.',
21
51
  annotations: { destructiveHint: true },
@@ -28,7 +58,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
28
58
  return runOrDiagnose(['sheets', 'delete-tab', spreadsheetId, tabName], { account });
29
59
  });
30
60
 
31
- // 3. Rename tab
32
61
  server.registerTool('gog_sheets_rename_tab', {
33
62
  description: 'Rename a sheet tab.',
34
63
  annotations: { destructiveHint: true },
@@ -42,7 +71,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
42
71
  return runOrDiagnose(['sheets', 'rename-tab', spreadsheetId, oldName, newName], { account });
43
72
  });
44
73
 
45
- // 4. Copy spreadsheet
46
74
  server.registerTool('gog_sheets_copy', {
47
75
  description: 'Copy a spreadsheet to a new spreadsheet with the given title.',
48
76
  inputSchema: {
@@ -57,7 +85,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
57
85
  return runOrDiagnose(args, { account });
58
86
  });
59
87
 
60
- // 5. Export spreadsheet
61
88
  server.registerTool('gog_sheets_export', {
62
89
  description: 'Export a spreadsheet as CSV, TSV, or PDF.',
63
90
  annotations: { readOnlyHint: true },
@@ -74,7 +101,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
74
101
  return runOrDiagnose(args, { account });
75
102
  });
76
103
 
77
- // 6. Freeze rows/columns
78
104
  server.registerTool('gog_sheets_freeze', {
79
105
  description: 'Freeze rows and/or columns in a sheet.',
80
106
  annotations: { destructiveHint: true },
@@ -93,7 +119,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
93
119
  return runOrDiagnose(args, { account });
94
120
  });
95
121
 
96
- // 7. Insert rows/columns
97
122
  server.registerTool('gog_sheets_insert', {
98
123
  description: 'Insert rows or columns into a sheet.',
99
124
  annotations: { destructiveHint: true },
@@ -113,7 +138,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
113
138
  return runOrDiagnose(args, { account });
114
139
  });
115
140
 
116
- // 8. Merge cells
117
141
  server.registerTool('gog_sheets_merge', {
118
142
  description: 'Merge cells in a range.',
119
143
  annotations: { destructiveHint: true },
@@ -129,7 +153,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
129
153
  return runOrDiagnose(args, { account });
130
154
  });
131
155
 
132
- // 9. Unmerge cells
133
156
  server.registerTool('gog_sheets_unmerge', {
134
157
  description: 'Unmerge previously merged cells in a range.',
135
158
  annotations: { destructiveHint: true },
@@ -142,24 +165,95 @@ export function registerExtraSheetsTools(server: McpServer): void {
142
165
  return runOrDiagnose(['sheets', 'unmerge', spreadsheetId, range], { account });
143
166
  });
144
167
 
145
- // 10. Format cells
146
168
  server.registerTool('gog_sheets_format', {
147
- description: 'Apply cell formatting (bold, colors, alignment, etc.) to a range. Pass format as a JSON CellFormat object.',
169
+ description: 'Apply cell formatting to a range. The named flags (bold, italic, backgroundColor, etc.) compose into a Sheets API CellFormat — use them for the 90% case. For full API control, pass formatJson + formatFields (Sheets CellFormat + field mask) directly.',
148
170
  annotations: { destructiveHint: true },
149
171
  inputSchema: {
150
172
  spreadsheetId: z.string().describe('Spreadsheet ID'),
151
173
  range: z.string().describe('Range to format (e.g. Sheet1!A1:C3)'),
152
- formatJson: z.string().describe('JSON CellFormat object (e.g. {"textFormat":{"bold":true}})'),
153
- formatFields: z.string().optional().describe('Comma-separated field mask (e.g. textFormat.bold,backgroundColor)'),
174
+ bold: z.boolean().optional().describe('Set bold'),
175
+ italic: z.boolean().optional().describe('Set italic'),
176
+ underline: z.boolean().optional().describe('Set underline'),
177
+ strikethrough: z.boolean().optional().describe('Set strikethrough'),
178
+ fontSize: z.number().optional().describe('Font size in points'),
179
+ fontFamily: z.string().optional().describe('Font family (e.g. Arial)'),
180
+ textColor: z.string().optional().describe('Text color as #RRGGBB or #RGB'),
181
+ backgroundColor: z.string().optional().describe('Cell background color as #RRGGBB or #RGB'),
182
+ horizontalAlignment: z.enum(['LEFT', 'CENTER', 'RIGHT']).optional().describe('Horizontal alignment'),
183
+ verticalAlignment: z.enum(['TOP', 'MIDDLE', 'BOTTOM']).optional().describe('Vertical alignment'),
184
+ wrapStrategy: z.enum(['OVERFLOW_CELL', 'LEGACY_WRAP', 'CLIP', 'WRAP']).optional().describe('Text wrap strategy'),
185
+ formatJson: z.string().optional().describe('Escape hatch: raw CellFormat JSON. When provided, named flags above are ignored and formatJson is sent as-is.'),
186
+ formatFields: z.string().optional().describe('Comma-separated field mask (e.g. textFormat.bold,backgroundColor). Required when using formatJson; auto-computed when using named flags.'),
154
187
  account: accountParam,
155
188
  },
156
- }, async ({ spreadsheetId, range, formatJson, formatFields, account }) => {
157
- const args = ['sheets', 'format', spreadsheetId, range, `--format-json=${formatJson}`];
189
+ }, async (rawArgs) => {
190
+ const a = rawArgs as {
191
+ spreadsheetId: string;
192
+ range: string;
193
+ bold?: boolean;
194
+ italic?: boolean;
195
+ underline?: boolean;
196
+ strikethrough?: boolean;
197
+ fontSize?: number;
198
+ fontFamily?: string;
199
+ textColor?: string;
200
+ backgroundColor?: string;
201
+ horizontalAlignment?: string;
202
+ verticalAlignment?: string;
203
+ wrapStrategy?: string;
204
+ formatJson?: string;
205
+ formatFields?: string;
206
+ account?: string;
207
+ };
208
+ let formatJson = a.formatJson;
209
+ let formatFields = a.formatFields;
210
+ // If the caller didn't pass raw JSON, compose it from the named flags.
211
+ if (!formatJson) {
212
+ const cellFormat: Record<string, unknown> = {};
213
+ const textFormat: Record<string, unknown> = {};
214
+ const fields: string[] = [];
215
+ if (a.bold !== undefined) { textFormat.bold = a.bold; fields.push('textFormat.bold'); }
216
+ if (a.italic !== undefined) { textFormat.italic = a.italic; fields.push('textFormat.italic'); }
217
+ if (a.underline !== undefined) { textFormat.underline = a.underline; fields.push('textFormat.underline'); }
218
+ if (a.strikethrough !== undefined) { textFormat.strikethrough = a.strikethrough; fields.push('textFormat.strikethrough'); }
219
+ if (a.fontSize !== undefined) { textFormat.fontSize = a.fontSize; fields.push('textFormat.fontSize'); }
220
+ if (a.fontFamily !== undefined) { textFormat.fontFamily = a.fontFamily; fields.push('textFormat.fontFamily'); }
221
+ if (a.textColor !== undefined) {
222
+ const rgb = hexToRgb(a.textColor);
223
+ if (!rgb) throw new Error(`Invalid textColor: ${a.textColor} (expected #RRGGBB or #RGB)`);
224
+ textFormat.foregroundColor = rgb;
225
+ fields.push('textFormat.foregroundColor');
226
+ }
227
+ if (Object.keys(textFormat).length > 0) cellFormat.textFormat = textFormat;
228
+ if (a.backgroundColor !== undefined) {
229
+ const rgb = hexToRgb(a.backgroundColor);
230
+ if (!rgb) throw new Error(`Invalid backgroundColor: ${a.backgroundColor} (expected #RRGGBB or #RGB)`);
231
+ cellFormat.backgroundColor = rgb;
232
+ fields.push('backgroundColor');
233
+ }
234
+ if (a.horizontalAlignment !== undefined) {
235
+ cellFormat.horizontalAlignment = a.horizontalAlignment;
236
+ fields.push('horizontalAlignment');
237
+ }
238
+ if (a.verticalAlignment !== undefined) {
239
+ cellFormat.verticalAlignment = a.verticalAlignment;
240
+ fields.push('verticalAlignment');
241
+ }
242
+ if (a.wrapStrategy !== undefined) {
243
+ cellFormat.wrapStrategy = a.wrapStrategy;
244
+ fields.push('wrapStrategy');
245
+ }
246
+ if (Object.keys(cellFormat).length === 0) {
247
+ throw new Error('gog_sheets_format requires at least one named flag or a formatJson value');
248
+ }
249
+ formatJson = JSON.stringify(cellFormat);
250
+ if (!formatFields) formatFields = fields.join(',');
251
+ }
252
+ const args = ['sheets', 'format', a.spreadsheetId, a.range, `--format-json=${formatJson}`];
158
253
  if (formatFields) args.push(`--format-fields=${formatFields}`);
159
- return runOrDiagnose(args, { account });
254
+ return runOrDiagnose(args, { account: a.account });
160
255
  });
161
256
 
162
- // 11. Number format
163
257
  server.registerTool('gog_sheets_number_format', {
164
258
  description: 'Set number format on a range (currency, percentage, date, etc.).',
165
259
  annotations: { destructiveHint: true },
@@ -177,7 +271,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
177
271
  return runOrDiagnose(args, { account });
178
272
  });
179
273
 
180
- // 12. Read format
181
274
  server.registerTool('gog_sheets_read_format', {
182
275
  description: 'Read cell formatting for a range.',
183
276
  annotations: { readOnlyHint: true },
@@ -193,7 +286,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
193
286
  return runOrDiagnose(args, { account });
194
287
  });
195
288
 
196
- // 13. Resize columns
197
289
  server.registerTool('gog_sheets_resize_columns', {
198
290
  description: 'Resize column widths. Use --auto for auto-fit or --width for a specific pixel width.',
199
291
  annotations: { destructiveHint: true },
@@ -211,7 +303,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
211
303
  return runOrDiagnose(args, { account });
212
304
  });
213
305
 
214
- // 14. Resize rows
215
306
  server.registerTool('gog_sheets_resize_rows', {
216
307
  description: 'Resize row heights. Use --auto for auto-fit or --height for a specific pixel height.',
217
308
  annotations: { destructiveHint: true },
@@ -229,7 +320,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
229
320
  return runOrDiagnose(args, { account });
230
321
  });
231
322
 
232
- // 15. Notes (read)
233
323
  server.registerTool('gog_sheets_notes', {
234
324
  description: 'Read cell notes in a range.',
235
325
  annotations: { readOnlyHint: true },
@@ -242,7 +332,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
242
332
  return runOrDiagnose(['sheets', 'notes', spreadsheetId, range], { account });
243
333
  });
244
334
 
245
- // 16. Update note
246
335
  server.registerTool('gog_sheets_update_note', {
247
336
  description: 'Add, update, or clear a cell note. Pass an empty string to clear the note.',
248
337
  annotations: { destructiveHint: true },
@@ -256,7 +345,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
256
345
  return runOrDiagnose(['sheets', 'update-note', spreadsheetId, range, `--note=${note}`], { account });
257
346
  });
258
347
 
259
- // 17. Links
260
348
  server.registerTool('gog_sheets_links', {
261
349
  description: 'List hyperlinks in a range.',
262
350
  annotations: { readOnlyHint: true },
@@ -269,7 +357,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
269
357
  return runOrDiagnose(['sheets', 'links', spreadsheetId, range], { account });
270
358
  });
271
359
 
272
- // 18. Named ranges list
273
360
  server.registerTool('gog_sheets_named_ranges_list', {
274
361
  description: 'List all named ranges in a spreadsheet.',
275
362
  annotations: { readOnlyHint: true },
@@ -281,7 +368,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
281
368
  return runOrDiagnose(['sheets', 'named-ranges', 'list', spreadsheetId], { account });
282
369
  });
283
370
 
284
- // 19. Named ranges get
285
371
  server.registerTool('gog_sheets_named_ranges_get', {
286
372
  description: 'Get a named range by name or ID.',
287
373
  annotations: { readOnlyHint: true },
@@ -294,7 +380,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
294
380
  return runOrDiagnose(['sheets', 'named-ranges', 'get', spreadsheetId, nameOrId], { account });
295
381
  });
296
382
 
297
- // 20. Named ranges add
298
383
  server.registerTool('gog_sheets_named_ranges_add', {
299
384
  description: 'Create a new named range.',
300
385
  inputSchema: {
@@ -307,7 +392,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
307
392
  return runOrDiagnose(['sheets', 'named-ranges', 'add', spreadsheetId, name, range], { account });
308
393
  });
309
394
 
310
- // 21. Named ranges update
311
395
  server.registerTool('gog_sheets_named_ranges_update', {
312
396
  description: 'Update a named range (change its name, range, or both).',
313
397
  annotations: { destructiveHint: true },
@@ -325,7 +409,6 @@ export function registerExtraSheetsTools(server: McpServer): void {
325
409
  return runOrDiagnose(args, { account });
326
410
  });
327
411
 
328
- // 22. Named ranges delete
329
412
  server.registerTool('gog_sheets_named_ranges_delete', {
330
413
  description: 'Delete a named range.',
331
414
  annotations: { destructiveHint: true },
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerExtraSheetsTools } from '../../src/tools/sheets-extra.js';
3
3
  import * as lib from '../../../gogcli-mcp/src/lib.js';
4
- import { setupExtrasHandlers, toText } from '../../../gogcli-mcp/tests/helpers/extras-harness.js';
4
+ import { setupHandlers as setupHandlersBase, toText } from '../../../gogcli-mcp/tests/helpers/test-harness.js';
5
5
 
6
6
  vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
7
7
  const actual = await importOriginal<typeof lib>();
@@ -11,7 +11,7 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
11
11
  };
12
12
  });
13
13
 
14
- const setupHandlers = () => setupExtrasHandlers(registerExtraSheetsTools);
14
+ const setupHandlers = () => setupHandlersBase(registerExtraSheetsTools);
15
15
 
16
16
  beforeEach(() => vi.clearAllMocks());
17
17
 
@@ -170,19 +170,132 @@ describe('gog_sheets_unmerge', () => {
170
170
 
171
171
  // 10. format
172
172
  describe('gog_sheets_format', () => {
173
- it('calls runOrDiagnose with correct args', async () => {
173
+ it('passes raw formatJson + formatFields through unchanged (escape hatch)', async () => {
174
174
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
175
175
  const handlers = setupHandlers();
176
176
  const fj = '{"textFormat":{"bold":true}}';
177
- await handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1:B2', formatJson: fj });
178
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'format', 'sid', 'A1:B2', `--format-json=${fj}`], { account: undefined });
177
+ await handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1:B2', formatJson: fj, formatFields: 'textFormat.bold' });
178
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
179
+ ['sheets', 'format', 'sid', 'A1:B2', `--format-json=${fj}`, '--format-fields=textFormat.bold'],
180
+ { account: undefined },
181
+ );
182
+ });
183
+
184
+ it('passes raw formatJson without formatFields', async () => {
185
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
186
+ const handlers = setupHandlers();
187
+ await handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1', formatJson: '{}' });
188
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
189
+ ['sheets', 'format', 'sid', 'A1', '--format-json={}'],
190
+ { account: undefined },
191
+ );
192
+ });
193
+
194
+ it('composes named flags into CellFormat JSON + auto-computed fields', async () => {
195
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
196
+ const handlers = setupHandlers();
197
+ await handlers.get('gog_sheets_format')!({
198
+ spreadsheetId: 'sid', range: 'A1:C3',
199
+ bold: true,
200
+ backgroundColor: '#FFF5D9',
201
+ wrapStrategy: 'WRAP',
202
+ verticalAlignment: 'TOP',
203
+ });
204
+ const call = vi.mocked(lib.runOrDiagnose).mock.calls[0]!;
205
+ const argv = call[0];
206
+ expect(argv[0]).toBe('sheets');
207
+ expect(argv[1]).toBe('format');
208
+ expect(argv[2]).toBe('sid');
209
+ expect(argv[3]).toBe('A1:C3');
210
+ const fmtArg = (argv[4] as string).replace(/^--format-json=/, '');
211
+ const parsed = JSON.parse(fmtArg);
212
+ expect(parsed.textFormat.bold).toBe(true);
213
+ expect(parsed.backgroundColor.red).toBeCloseTo(1.0);
214
+ expect(parsed.backgroundColor.green).toBeCloseTo(245 / 255);
215
+ expect(parsed.backgroundColor.blue).toBeCloseTo(217 / 255);
216
+ expect(parsed.wrapStrategy).toBe('WRAP');
217
+ expect(parsed.verticalAlignment).toBe('TOP');
218
+ const fieldsArg = argv[5] as string;
219
+ expect(fieldsArg).toMatch(/^--format-fields=/);
220
+ expect(fieldsArg).toContain('textFormat.bold');
221
+ expect(fieldsArg).toContain('backgroundColor');
222
+ expect(fieldsArg).toContain('wrapStrategy');
223
+ expect(fieldsArg).toContain('verticalAlignment');
224
+ });
225
+
226
+ it('composes all named flags including textColor + alignment', async () => {
227
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
228
+ const handlers = setupHandlers();
229
+ await handlers.get('gog_sheets_format')!({
230
+ spreadsheetId: 'sid', range: 'A1',
231
+ italic: true,
232
+ underline: true,
233
+ strikethrough: true,
234
+ fontSize: 14,
235
+ fontFamily: 'Inter',
236
+ textColor: '#FFF',
237
+ horizontalAlignment: 'CENTER',
238
+ });
239
+ const call = vi.mocked(lib.runOrDiagnose).mock.calls[0]!;
240
+ const fmtArg = (call[0][4] as string).replace(/^--format-json=/, '');
241
+ const parsed = JSON.parse(fmtArg);
242
+ expect(parsed.textFormat.italic).toBe(true);
243
+ expect(parsed.textFormat.underline).toBe(true);
244
+ expect(parsed.textFormat.strikethrough).toBe(true);
245
+ expect(parsed.textFormat.fontSize).toBe(14);
246
+ expect(parsed.textFormat.fontFamily).toBe('Inter');
247
+ expect(parsed.textFormat.foregroundColor.red).toBeCloseTo(1);
248
+ expect(parsed.textFormat.foregroundColor.green).toBeCloseTo(1);
249
+ expect(parsed.textFormat.foregroundColor.blue).toBeCloseTo(1);
250
+ expect(parsed.horizontalAlignment).toBe('CENTER');
251
+ });
252
+
253
+ it('rejects bad hex in textColor', async () => {
254
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
255
+ const handlers = setupHandlers();
256
+ await expect(
257
+ handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1', textColor: 'not-a-hex' }),
258
+ ).rejects.toThrow(/Invalid textColor/);
259
+ });
260
+
261
+ it('rejects bad hex in backgroundColor', async () => {
262
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
263
+ const handlers = setupHandlers();
264
+ await expect(
265
+ handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1', backgroundColor: 'orange' }),
266
+ ).rejects.toThrow(/Invalid backgroundColor/);
179
267
  });
180
268
 
181
- it('includes --format-fields when provided', async () => {
269
+ it('rejects no-op calls with neither flags nor formatJson', async () => {
270
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
271
+ const handlers = setupHandlers();
272
+ await expect(
273
+ handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1' }),
274
+ ).rejects.toThrow(/requires at least one named flag/);
275
+ });
276
+
277
+ it('honors caller-provided formatFields when also using named flags', async () => {
278
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
279
+ const handlers = setupHandlers();
280
+ await handlers.get('gog_sheets_format')!({
281
+ spreadsheetId: 'sid', range: 'A1',
282
+ bold: true,
283
+ formatFields: 'textFormat.bold,textFormat.italic',
284
+ });
285
+ const call = vi.mocked(lib.runOrDiagnose).mock.calls[0]!;
286
+ expect(call[0][5]).toBe('--format-fields=textFormat.bold,textFormat.italic');
287
+ });
288
+ });
289
+
290
+ describe('gog_sheets_list_tabs', () => {
291
+ it('uses metadata with a --select projection', async () => {
182
292
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
183
293
  const handlers = setupHandlers();
184
- await handlers.get('gog_sheets_format')!({ spreadsheetId: 'sid', range: 'A1', formatJson: '{}', formatFields: 'textFormat.bold' });
185
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['sheets', 'format', 'sid', 'A1', '--format-json={}', '--format-fields=textFormat.bold'], { account: undefined });
294
+ await handlers.get('gog_sheets_list_tabs')!({ spreadsheetId: 'sid' });
295
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
296
+ ['sheets', 'metadata', 'sid', '--select=sheets.properties.sheetId,sheets.properties.title,sheets.properties.index,sheets.properties.gridProperties'],
297
+ { account: undefined },
298
+ );
186
299
  });
187
300
  });
188
301