xlsx-for-ai 2.25.2 → 2.26.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.
Files changed (2) hide show
  1. package/index.js +171 -0
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -151,6 +151,7 @@ async function runClean(opts, absPath) {
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
153
  const STAMP_SUBCOMMANDS = new Set(['stamp', 'verify-stamp', 'receipt', 'verify-receipt']);
154
+ const HEAL_SUBCOMMANDS = new Set(['heal']);
154
155
 
155
156
  // Strip _meta.file_b64 before writing the meta block to stdout. The
156
157
  // stamped/receipted workbook can be megabytes; dumping it to a terminal
@@ -214,6 +215,172 @@ function loadChecksFile(checksPath) {
214
215
  return parsed;
215
216
  }
216
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // Heal subcommand — exposes the healer-deep HTTP routes from the CLI.
220
+ //
221
+ // xlsx-for-ai heal <path> diagnose-only (default)
222
+ // xlsx-for-ai heal <path> --diagnose-only explicit form of the default
223
+ // xlsx-for-ai heal <path> --operation <op> --params <json> [--mode <m>] [--out <path>]
224
+ // xlsx-for-ai heal <path> --format text|json [other flags]
225
+ //
226
+ // `--intent`/`--from`/`--to` are reserved for v1.1 once the
227
+ // /api/v1/tools/xlsx_healer_intent route ships; the CLI rejects
228
+ // those flags today so callers see a clean "v1.1" message rather
229
+ // than a server 404.
230
+ // ---------------------------------------------------------------------------
231
+
232
+ async function runHealSubcommand(rest) {
233
+ if (rest.length === 0 || rest[0].startsWith('-')) {
234
+ process.stderr.write(
235
+ 'Usage: xlsx-for-ai heal <file.xlsx> [--diagnose-only | --operation <op> --params <json>] [--mode as_copy|in_place] [--out <path>] [--format text|json]\n',
236
+ );
237
+ process.exit(2);
238
+ }
239
+ const filePath = path.resolve(rest[0]);
240
+ if (!fs.existsSync(filePath)) {
241
+ process.stderr.write(`File not found: ${filePath}\n`);
242
+ process.exit(4);
243
+ }
244
+
245
+ // Parse flags
246
+ let diagnoseOnly = false;
247
+ let operation = null;
248
+ let paramsJson = null;
249
+ let mode = 'as_copy';
250
+ let outPath = null;
251
+ let format = 'text';
252
+ for (let i = 1; i < rest.length; i++) {
253
+ const a = rest[i];
254
+ if (a === '--diagnose-only') diagnoseOnly = true;
255
+ else if (a === '--operation') operation = nextRequiredArg(rest, i++, '--operation');
256
+ else if (a === '--params') paramsJson = nextRequiredArg(rest, i++, '--params');
257
+ else if (a === '--mode') mode = nextRequiredArg(rest, i++, '--mode');
258
+ else if (a === '--out') outPath = nextRequiredArg(rest, i++, '--out');
259
+ else if (a === '--format') format = nextRequiredArg(rest, i++, '--format');
260
+ else if (a === '--intent' || a === '--from' || a === '--to') {
261
+ process.stderr.write(`xlsx-for-ai heal: ${a} is reserved for v1.1 (intent-driven cures via /xlsx_healer_intent); not yet wired\n`);
262
+ process.exit(2);
263
+ } else {
264
+ process.stderr.write(`Unknown flag: ${a}\n`);
265
+ process.exit(2);
266
+ }
267
+ }
268
+
269
+ // Validate mutually-exclusive shapes early — clearer than letting
270
+ // the server emit `conflicting_repair_directives` on its O8 check.
271
+ if (diagnoseOnly && operation) {
272
+ process.stderr.write('xlsx-for-ai heal: --diagnose-only and --operation are mutually exclusive\n');
273
+ process.exit(2);
274
+ }
275
+ if (!diagnoseOnly && !operation) {
276
+ // Default to diagnose-only — first-touch use of `xlsx-for-ai heal`
277
+ // should show the user what's wrong before they pick a cure.
278
+ diagnoseOnly = true;
279
+ }
280
+ if (mode !== 'as_copy' && mode !== 'in_place') {
281
+ process.stderr.write(`xlsx-for-ai heal: --mode must be 'as_copy' or 'in_place' (got '${mode}')\n`);
282
+ process.exit(2);
283
+ }
284
+ if (format !== 'text' && format !== 'json') {
285
+ process.stderr.write(`xlsx-for-ai heal: --format must be 'text' or 'json' (got '${format}')\n`);
286
+ process.exit(2);
287
+ }
288
+
289
+ await ensureRegistered();
290
+ const fileB64 = fs.readFileSync(filePath).toString('base64');
291
+
292
+ // ---- diagnose path -----------------------------------------------------
293
+ if (diagnoseOnly) {
294
+ let result;
295
+ try {
296
+ result = await callTool('xlsx_healer_diagnose', { file_b64: fileB64 });
297
+ } catch (err) {
298
+ // friendlyCliError (defined above in this file) maps known
299
+ // API error codes to short canned messages — raw err.message
300
+ // is suppressed unless XFA_DEBUG=1 is set, so internal server
301
+ // detail / paths / stack traces never reach the user's
302
+ // terminal or CI logs by default. Same sanitization shape
303
+ // the other subcommands use for API failures.
304
+ process.stderr.write(friendlyCliError('xlsx-for-ai heal --diagnose-only', err) + '\n');
305
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
306
+ }
307
+ const meta = (result && result._meta) || {};
308
+ if (format === 'json') {
309
+ // metaForStdout strips file_b64 if present — diagnose has none,
310
+ // but the helper is safe to call unconditionally.
311
+ process.stdout.write(JSON.stringify(metaForStdout(meta) || {}, null, 2) + '\n');
312
+ } else {
313
+ const text = (result.content || []).map((c) => c.text).join('\n');
314
+ process.stdout.write(text + '\n');
315
+ }
316
+ return 0;
317
+ }
318
+
319
+ // ---- cure path ---------------------------------------------------------
320
+ let cureParams = {};
321
+ if (paramsJson !== null) {
322
+ try {
323
+ cureParams = JSON.parse(paramsJson);
324
+ } catch (e) {
325
+ process.stderr.write(`xlsx-for-ai heal: --params is not valid JSON: ${e.message}\n`);
326
+ process.exit(2);
327
+ }
328
+ if (cureParams === null || typeof cureParams !== 'object' || Array.isArray(cureParams)) {
329
+ process.stderr.write('xlsx-for-ai heal: --params must be a JSON object\n');
330
+ process.exit(2);
331
+ }
332
+ }
333
+
334
+ const body = { file_b64: fileB64, operation, cure_params: cureParams, mode };
335
+ let result;
336
+ try {
337
+ result = await callTool('xlsx_healer_cure', body);
338
+ } catch (err) {
339
+ // Same sanitization shape as the diagnose path — friendlyCliError
340
+ // (above) maps known codes to canned messages; raw err.message
341
+ // only surfaces with XFA_DEBUG=1 for incident triage.
342
+ process.stderr.write(friendlyCliError(`xlsx-for-ai heal --operation ${operation}`, err) + '\n');
343
+ process.exit(err.code === 'API_UNREACHABLE' || err.code === 'API_SERVER_ERROR' ? 3 : 1);
344
+ }
345
+
346
+ const meta = (result && result._meta) || {};
347
+
348
+ // Write the cured workbook to disk when `as_copy` mode produced
349
+ // bytes. `in_place` mode is server-side conceptual; the client
350
+ // still receives the cured bytes here (we'd overwrite the input
351
+ // file). Refuse to overwrite the source unless the caller passed
352
+ // --out explicitly.
353
+ if (meta.file_b64) {
354
+ let resolvedOut = outPath;
355
+ if (!resolvedOut) {
356
+ const parsed = path.parse(filePath);
357
+ resolvedOut = path.join(parsed.dir, `${parsed.name}-healed${parsed.ext || '.xlsx'}`);
358
+ }
359
+ const sourceIsTarget = path.resolve(resolvedOut) === path.resolve(filePath);
360
+ if (sourceIsTarget && mode !== 'in_place') {
361
+ process.stderr.write(
362
+ 'xlsx-for-ai heal: refusing to overwrite source — pass --out <other-path> or --mode in_place\n',
363
+ );
364
+ process.exit(2);
365
+ }
366
+ try {
367
+ fs.writeFileSync(resolvedOut, Buffer.from(meta.file_b64, 'base64'));
368
+ } catch (e) {
369
+ process.stderr.write(`xlsx-for-ai heal: failed to write ${resolvedOut}: ${e.message}\n`);
370
+ process.exit(4);
371
+ }
372
+ process.stderr.write(`Wrote ${resolvedOut}\n`);
373
+ }
374
+
375
+ if (format === 'json') {
376
+ process.stdout.write(JSON.stringify(metaForStdout(meta) || {}, null, 2) + '\n');
377
+ } else {
378
+ const text = (result.content || []).map((c) => c.text).join('\n');
379
+ process.stdout.write(text + '\n');
380
+ }
381
+ return 0;
382
+ }
383
+
217
384
  async function runStampSubcommand(subcmd, rest) {
218
385
  if (rest.length === 0 || rest[0].startsWith('-')) {
219
386
  process.stderr.write(`Usage: xlsx-for-ai ${subcmd} <path> [...]\n`);
@@ -342,6 +509,10 @@ async function main() {
342
509
  const code = await runStampSubcommand(argv[0], argv.slice(1));
343
510
  process.exit(code);
344
511
  }
512
+ if (argv.length > 0 && HEAL_SUBCOMMANDS.has(argv[0])) {
513
+ const code = await runHealSubcommand(argv.slice(1));
514
+ process.exit(code);
515
+ }
345
516
 
346
517
  const opts = parseArgs(argv);
347
518
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "xlsx-for-ai",
3
3
  "mcpName": "io.github.senoff/xlsx-for-ai",
4
- "version": "2.25.2",
4
+ "version": "2.26.0",
5
5
  "description": "The MCP server that makes LLMs reliable on real-world Excel spreadsheets. Thin npm client over a hosted API — read, write, diff, redact, and supervise .xlsx files from any MCP-aware agent.",
6
6
  "main": "index.js",
7
7
  "bin": {