xlsx-for-ai 1.3.1 → 1.4.1

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/README.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  > 👋 **New here? Not a programmer?** → [Read WHY.md for the plain-English version](WHY.md). The README below is the technical reference.
4
4
 
5
- Converts spreadsheets into text, **markdown**, JSON, SQL, or schema dumps that AI coding agents can actually read.
5
+ **The bidirectional bridge between spreadsheets and AI agents.** Reads `.xlsx` (and `.xls`, `.xlsb`, `.ods`, `.csv`, `.tsv`) into the formats LLMs actually consume — markdown, JSON, text, SQL and writes spreadsheets back out from AI-generated specs. Same tool, both directions.
6
6
 
7
- AI tools — Claude, Cursor, Copilot, ChatGPT, and other LLM coding agents — can read text files but **not** `.xlsx` binaries. This CLI bridges the gap.
7
+ AI tools — Claude, Cursor, Copilot, ChatGPT, and other LLM coding agents — can read text files but **not** `.xlsx` binaries. This CLI closes the loop:
8
+
9
+ **📖 Read mode (default)** — turn any spreadsheet into LLM-readable output. Every formula, every named range, every merged cell, every fill color, every cross-sheet reference. No more pasting numbers and losing context.
10
+
11
+ **✍️ Write mode (`xlsx-for-ai write`)** — turn an AI-generated JSON or markdown spec into a real `.xlsx` file. Closes the round-trip so an agent that *reviews* your spreadsheet can also *deliver the corrected file*. The output includes a `_xlsx-for-ai` review tab explaining every structural change the round-trip made (with risks, tradeoffs, and overrides) — the supervisor model: AI does the work, the human stays in control of every decision. Verified lossless on 29/30 real workbooks.
8
12
 
9
13
  **Input formats:** `.xlsx` `.xls` `.xlsb` `.ods` `.csv` `.tsv`
10
14
 
11
- **Output modes:** text dump, markdown tables (best LLM comprehension per token), JSON, SQL `CREATE TABLE`+`INSERT`, inferred schema, workbook diff.
15
+ **Output modes:** text dump, markdown tables (best LLM comprehension per token), JSON, SQL `CREATE TABLE`+`INSERT`, inferred schema, workbook diff, real `.xlsx` (write mode).
12
16
 
13
17
  It extracts everything a human would see in Excel:
14
18
 
@@ -111,6 +115,75 @@ npx xlsx-for-ai data.xlsx "Sheet1" --stdout --max-rows 50 --compact
111
115
  | `--stream` | Streaming reader for huge `.xlsx` files (>100MB); emits row-by-row, drops some sheet metadata |
112
116
  | `-h`, `--help` | Show help |
113
117
 
118
+ ### Write mode (`xlsx-for-ai write`)
119
+
120
+ The `write` sub-command produces a real `.xlsx` from a JSON or markdown spec.
121
+
122
+ ```bash
123
+ xlsx-for-ai write spec.json # → spec.xlsx
124
+ xlsx-for-ai write spec.json -o report.xlsx # explicit output
125
+ xlsx-for-ai write report.md # markdown table → xlsx
126
+ cat spec.json | xlsx-for-ai write - # stdin
127
+ ```
128
+
129
+ Minimum JSON spec:
130
+
131
+ ```json
132
+ {
133
+ "name": "Budget",
134
+ "headers": ["Category", "Q1", "Q2"],
135
+ "rows": [
136
+ ["Marketing", 10000, 12000],
137
+ ["R&D", 50000, 55000]
138
+ ]
139
+ }
140
+ ```
141
+
142
+ Multi-sheet, with formulas:
143
+
144
+ ```json
145
+ {
146
+ "sheets": [
147
+ {
148
+ "name": "Summary",
149
+ "headers": ["Region", "Revenue", "Cost", "Profit"],
150
+ "rows": [
151
+ ["North", 100, 60, {"formula": "=B2-C2"}],
152
+ ["South", 200, 110, {"formula": "=B3-C3"}]
153
+ ],
154
+ "frozen": {"rowSplit": 1, "colSplit": 0}
155
+ },
156
+ {
157
+ "name": "Detail",
158
+ "headers": ["SKU", "Qty"],
159
+ "rows": [["A", 10], ["B", 20]]
160
+ }
161
+ ],
162
+ "namedRanges": {"Profits": "Summary!D2:D3"}
163
+ }
164
+ ```
165
+
166
+ **Round-trip:** the output of `xlsx-for-ai data.xlsx --json` is a valid input to `xlsx-for-ai write`, so reading then re-writing reproduces the file (verified on 29/30 real workbooks; the one MINOR is a CRLF→LF normalization in shared strings — visible content is identical).
167
+
168
+ **Markdown spec:** one or more tables; `## Sheet Name` headings split into multiple sheets. Backtick-fenced cells become formulas (e.g., `` `=A1+B1` ``). Numbers, booleans, and ISO dates auto-detect.
169
+
170
+ **v1 limitations:** edit-in-place (deferred to v1.5), charts, pivot tables, conditional formatting, images, macros — none of these are written. Shared formulas degrade to their cached values (formula link is lost; computed value is preserved).
171
+
172
+ #### The `_xlsx-for-ai` review tab
173
+
174
+ When the round-trip introduces any lossy structural changes (shared-formula degradation, line-ending normalization, etc.), `xlsx-for-ai write` adds a `_xlsx-for-ai` sheet to the output as the last tab. It's a **review note**, not just a warning list — for each issue type it explains:
175
+
176
+ - **What happened** — the source structure that couldn't be preserved
177
+ - **What we did** — the choice the tool made
178
+ - **Risk** — what could go wrong (e.g., *"if you edit cells the formula depended on, they won't recalculate"*)
179
+ - **Tradeoff** — what's worse about this choice vs. alternatives
180
+ - **Alternative** — exactly what flag/source change to apply if you want different behavior
181
+ - **Affected cells** — the specific refs, plus a full detail table at the bottom
182
+
183
+ The point: the user (or an AI agent reading the file) can understand every decision the tool made and override any of them. Same shape as a code reviewer's PR comment — observation + reasoning + alternative.
184
+
185
+ `--no-report` suppresses the tab if you want byte-clean output (useful for CI / round-trip tests). The `--diff` mode also ignores the `_xlsx-for-ai` tab automatically so it doesn't pollute change reports.
186
+
114
187
  Output files are written to `.xlsx-read/` in the current working directory.
115
188
  The path(s) are printed to stdout so your agent knows where to read.
116
189
 
package/WHY.md CHANGED
@@ -32,9 +32,56 @@ A few examples people find useful:
32
32
  - **Walk through a 50-tab model someone else built** and have the AI explain how the sheets reference each other.
33
33
  - **Process a folder of legacy `.xls` files** that won't even open in modern Excel without complaint.
34
34
 
35
+ But the biggest unlock is the next thing.
36
+
37
+ ## Now your AI can hand you back the file — not just words
38
+
39
+ Before, even once AI could read your spreadsheet, it could only *tell* you what to change. You'd still have to translate its advice into actual cell edits yourself. Tedious for two cells. Impossible for fifty.
40
+
41
+ Now the AI can do the editing for you. Same starting moment — you ask Claude to review your tax estimate, or update Q4 numbers in your forecast, or fix a broken cap table — but instead of describing the corrections, it builds you the actual fixed `.xlsx` and hands it back.
42
+
43
+ A real moment that gets unlocked:
44
+
45
+ > **You:** *"Here's our 2026 budget V11. Sales asked us to add a Travel line and bump Marketing to $52K each quarter. Update it."*
46
+ >
47
+ > **Claude (after a moment):** *"Done. I added a Travel row at the bottom with placeholder zeros (since you didn't give numbers), bumped Marketing to $52,000 across all four quarters, and updated the totals row to reflect the new sum. Here's V12 — I left a note in the file explaining everything I did."*
48
+
49
+ You open V12 in Excel. Marketing is updated. Travel exists. Totals are right. Then you flip to the rightmost tab — `_xlsx-for-ai` — and find what looks like a careful editor's notes on the changes.
50
+
51
+ ## What's in the review tab
52
+
53
+ It's the AI's note to you about exactly what it changed and why, written in plain English. For each kind of change, you get a small block like this:
54
+
55
+ > **Issue: Marketing line update** *(4 cells)*
56
+ >
57
+ > **What happened.** You asked to bump Marketing to $52,000 per quarter.
58
+ >
59
+ > **What we did.** Updated B12, C12, D12, E12 to $52,000 each.
60
+ >
61
+ > **Risk.** The totals row (row 20) recomputes automatically — confirm the new bottom-line totals match what you expected.
62
+ >
63
+ > **Alternative.** If you wanted Marketing scaled differently per quarter (e.g., higher in Q4), tell me and I'll redo it.
64
+
65
+ You can read the whole tab in 30 seconds. Then you either accept what the AI did, or push back on any individual item. Same shape as a careful editor marking up your draft — observation, reasoning, and a clear way to override.
66
+
67
+ This is on purpose. The tool is designed around the **supervisor** model: AI does the work, but the human stays in control of every decision. The review tab is what makes that real — without it, the AI would be making silent changes you'd only discover by accident later. With it, every choice the AI made is visible, named, and reversible.
68
+
69
+ ## Why this matters
70
+
71
+ Without the corrected file, AI is a really expensive consultant. It looks at your spreadsheet, talks for a while, and leaves you with a list of things to do yourself. No leverage on the actual work.
72
+
73
+ With the corrected file, AI is more like a junior analyst. It does the work, hands you the result, explains its reasoning, and waits for your review. Same role you've always wanted — without the hourly rate.
74
+
35
75
  ## How to actually use it
36
76
 
37
- It's a small command-line tool. Once a programmer sets it up (one line: `npm install -g xlsx-for-ai`), you don't have to think about it again — your AI tools pick it up automatically and start using it whenever they encounter a spreadsheet.
77
+ You don't run anything. Your AI does.
78
+
79
+ 1. **Install once.** A programmer (or you, if you're comfortable with one terminal command) runs `npm install -g xlsx-for-ai`. Then forget about it.
80
+ 2. **Drop a file into Claude, Cursor, Copilot, or ChatGPT** (the desktop apps with code execution, or any agent setup that can run commands). The AI picks up the tool automatically when it sees a spreadsheet.
81
+ 3. **Ask whatever you want** — review, fix errors, update numbers, generate reports, compare versions, restructure.
82
+ 4. **The AI hands back** either a text answer (when that's what you asked for) or a real `.xlsx` file with the review tab (when you asked for changes).
83
+
84
+ Most users never type a command.
38
85
 
39
86
  If you're the programmer doing the install, the [README](README.md) has the full reference. If you're handing this to a programmer to set up for you, that link is what they'll need.
40
87
 
@@ -42,6 +89,6 @@ If you're the programmer doing the install, the [README](README.md) has the full
42
89
 
43
90
  Spreadsheet libraries are designed for developers building software *on top of* spreadsheets. They output JavaScript objects, database rows, raw bytes — formats other programs consume. None of them were designed for the case where the consumer is a language model and the goal is a text format the model can actually understand.
44
91
 
45
- `xlsx-for-ai` is the first one built specifically for that. The output is shaped for an LLM's context window — markdown tables when the model just needs to read, structured JSON when it needs to reason, token-aware truncation when the spreadsheet is too big to fit.
92
+ `xlsx-for-ai` is the first one built specifically for that. The output is shaped for an LLM's context window — markdown tables when the model just needs to read, structured JSON when it needs to reason, token-aware truncation when the spreadsheet is too big to fit, and a real `.xlsx` writer that produces a file you can hand back to a human along with a built-in note explaining everything that changed.
46
93
 
47
94
  It's a small tool. It just happens to fix the one thing standing between AI assistants and the file format most knowledge work actually lives in.
@@ -1,10 +1,14 @@
1
1
  ---
2
- description: Reading and converting spreadsheets (.xlsx, .xls, .xlsb, .ods, .csv, .tsv) for AI agents
2
+ description: Reading, writing, and converting spreadsheets (.xlsx, .xls, .xlsb, .ods, .csv, .tsv) for AI agents
3
3
  globs:
4
4
  alwaysApply: true
5
5
  ---
6
6
 
7
- # Reading Spreadsheet Files
7
+ # Reading and Writing Spreadsheets
8
+
9
+ This tool does both directions: read existing spreadsheets into LLM-readable text/JSON/markdown, AND build new `.xlsx` files from JSON or markdown specs.
10
+
11
+ ## Reading
8
12
 
9
13
  The Read tool cannot open binary spreadsheet files directly. When you need to inspect or process a spreadsheet, use `xlsx-for-ai`.
10
14
 
@@ -93,8 +97,43 @@ npx xlsx-for-ai v1.xlsx --diff v2.xlsx --stdout
93
97
  npx xlsx-for-ai dump.xlsx --stream --stdout --max-rows 1000
94
98
  ```
95
99
 
100
+ ## Writing
101
+
102
+ Produce a real `.xlsx` from a JSON or markdown spec — closes the round-trip when an agent reads a spreadsheet, modifies it, and needs to deliver the corrected file.
103
+
104
+ ```bash
105
+ xlsx-for-ai write spec.json # → spec.xlsx
106
+ xlsx-for-ai write spec.json -o report.xlsx # explicit output path
107
+ cat spec.json | xlsx-for-ai write - # spec from stdin
108
+ ```
109
+
110
+ **JSON spec** — minimum (single sheet):
111
+ ```json
112
+ { "name": "Budget", "headers": ["Cat", "Q1"], "rows": [["Marketing", 10000]] }
113
+ ```
114
+
115
+ **JSON spec** — multi-sheet with formulas:
116
+ ```json
117
+ {
118
+ "sheets": [
119
+ { "name": "Summary", "headers": ["Region", "Total"],
120
+ "rows": [["North", {"formula": "=SUM(Detail!B:B)"}]] }
121
+ ],
122
+ "namedRanges": {"Totals": "Summary!B2:B5"}
123
+ }
124
+ ```
125
+
126
+ **Round-trip:** `--json` output is a valid `write` input. Read → modify → write produces an updated workbook. Verified lossless on 29/30 real workbooks tested.
127
+
128
+ **Spec fields per sheet:** `name`, `headers` (optional), `rows` (or `cells` for per-cell mode), `frozen`, `columnWidths`, `numberFormat`, `merges`, `autoFilter`, `namedRanges`. Cell values can be plain literals, `{formula: "=..."}`, or `{hyperlink: "...", text: "..."}`.
129
+
130
+ **v1 limitations** (intentional — document and skip):
131
+ - Edit-in-place (rewrites the whole file; deferred to v1.5)
132
+ - Charts, pivot tables, conditional formatting, images, macros (not written)
133
+ - Shared formulas degrade to their cached values
134
+
96
135
  ## Important
97
136
 
98
- - Output goes to `.xlsx-read/` in the current working directory — add this to `.gitignore`.
99
- - For huge files, prefer `--max-tokens` over `--max-rows` if you're targeting an LLM context window — token count and row count don't correlate.
137
+ - Read output goes to `.xlsx-read/` in the current working directory — add to `.gitignore`.
138
+ - For huge files, prefer `--max-tokens` over `--max-rows` if targeting an LLM context window.
100
139
  - The package was previously named `cursor-reads-xlsx` — that command name still works as an alias.
package/index.js CHANGED
@@ -82,10 +82,14 @@ function parseArgs(argv) {
82
82
 
83
83
  function printHelp() {
84
84
  console.log(`Usage: npx xlsx-for-ai <file> [sheetName] [options]
85
+ npx xlsx-for-ai write <spec> [-o output.xlsx] (build .xlsx from a spec)
85
86
 
86
87
  Converts spreadsheets to text, markdown, JSON, SQL, or schema dumps that AI
87
88
  coding agents can read. Preserves values, formulas, formatting, layout.
88
89
 
90
+ The 'write' sub-command does the reverse: takes a JSON or markdown spec and
91
+ produces an .xlsx file. Run 'xlsx-for-ai write --help' for details.
92
+
89
93
  Input formats: .xlsx .xls .xlsb .ods .csv .tsv
90
94
 
91
95
  Output modes (mutually exclusive; default = text):
@@ -233,9 +237,13 @@ function plainValue(v) {
233
237
  if (typeof v === 'object') {
234
238
  if (v.richText) return v.richText.map(r => r.text).join('');
235
239
  if (v.hyperlink) return v.text || v.hyperlink;
236
- if (v.formula || v.sharedFormula) {
240
+ // Recognize all four shapes formulas can take in our pipeline:
241
+ // ExcelJS read: {formula, result} or {sharedFormula, result}
242
+ // --json output: {formula, result} or {sharedFormulaRef, result}
243
+ if (v.formula || v.sharedFormula || v.sharedFormulaRef) {
237
244
  const r = v.result;
238
245
  if (r == null) return null;
246
+ if (r instanceof Date) return r.toISOString().slice(0, 10);
239
247
  if (typeof r === 'object') {
240
248
  if (r.error) return `#${r.error}`;
241
249
  if (r.richText) return r.richText.map(x => x.text).join('');
@@ -810,8 +818,11 @@ function evaluateWorkbook(wb) {
810
818
 
811
819
  function diffWorkbooks(wbA, wbB, opts = {}) {
812
820
  const out = [];
813
- const sheetsA = new Map(wbA.worksheets.map(s => [s.name, s]));
814
- const sheetsB = new Map(wbB.worksheets.map(s => [s.name, s]));
821
+ // Skip the tool's own report tab — it's metadata, not user data, so it
822
+ // shouldn't show up as "added" or "changed" in user-facing diffs.
823
+ const isReport = (name) => name === '_xlsx-for-ai';
824
+ const sheetsA = new Map(wbA.worksheets.filter(s => !isReport(s.name)).map(s => [s.name, s]));
825
+ const sheetsB = new Map(wbB.worksheets.filter(s => !isReport(s.name)).map(s => [s.name, s]));
815
826
  const allNames = new Set([...sheetsA.keys(), ...sheetsB.keys()]);
816
827
 
817
828
  for (const name of allNames) {
@@ -988,8 +999,642 @@ function listSheets(wb) {
988
999
  // Main
989
1000
  // ---------------------------------------------------------------------------
990
1001
 
1002
+ // ---------------------------------------------------------------------------
1003
+ // Write mode (#8) — JSON/markdown spec → .xlsx
1004
+ //
1005
+ // V1 scope: create-from-scratch only. Edit-in-place is deferred (ExcelJS would
1006
+ // need to round-trip every detail of an existing file, which it doesn't do
1007
+ // faithfully — that's a separate effort using xlsx-populate or a patch engine).
1008
+ //
1009
+ // Accepted inputs:
1010
+ // - JSON: strict subset of our --json output (round-trips). Either a
1011
+ // single-sheet object or {sheets: [...]} for multi-sheet.
1012
+ // - Markdown: one or more tables; "## Sheet Name" headings split into
1013
+ // multiple sheets. No headings = single sheet.
1014
+ // - '-' as the spec path: read spec from stdin (format auto-detected).
1015
+ // ---------------------------------------------------------------------------
1016
+
1017
+ function parseWriteArgs(argv) {
1018
+ const opts = { positional: [], output: null, noReport: false, help: false };
1019
+ let i = 0;
1020
+ while (i < argv.length) {
1021
+ const a = argv[i];
1022
+ if (a === '-h' || a === '--help') opts.help = true;
1023
+ else if (a === '-o' || a === '--output') opts.output = argv[++i];
1024
+ else if (a === '--no-report') opts.noReport = true;
1025
+ else opts.positional.push(a);
1026
+ i++;
1027
+ }
1028
+ return opts;
1029
+ }
1030
+
1031
+ function printWriteHelp() {
1032
+ console.log(`Usage: xlsx-for-ai write <spec> [-o output.xlsx]
1033
+
1034
+ Builds an .xlsx file from a spec. Spec formats:
1035
+ - JSON — strict subset of xlsx-for-ai's --json output (round-trips)
1036
+ - Markdown — one or more tables; "## Sheet Name" headings split sheets
1037
+ - '-' — read spec from stdin (format auto-detected by first non-blank char)
1038
+
1039
+ Options:
1040
+ -o, --output PATH Output xlsx path (default: <spec basename>.xlsx)
1041
+ --no-report Suppress the "_xlsx-for-ai" review tab (advanced; for
1042
+ pipelines that want byte-clean output without metadata)
1043
+ -h, --help Show this help
1044
+
1045
+ Examples:
1046
+ xlsx-for-ai write spec.json
1047
+ xlsx-for-ai write spec.json -o report.xlsx
1048
+ xlsx-for-ai write report.md
1049
+ cat spec.json | xlsx-for-ai write -
1050
+
1051
+ JSON spec — minimum (single sheet):
1052
+ {
1053
+ "name": "Budget",
1054
+ "headers": ["Category", "Q1", "Q2"],
1055
+ "rows": [
1056
+ ["Marketing", 10000, 12000],
1057
+ ["R&D", 50000, 55000]
1058
+ ]
1059
+ }
1060
+
1061
+ JSON spec — multi-sheet:
1062
+ { "sheets": [ {...}, {...} ], "namedRanges": {"Totals": "Sheet1!B2:C5"} }
1063
+
1064
+ JSON spec — formulas:
1065
+ rows can include { "formula": "=SUM(B2:B5)" } in place of a literal value.
1066
+ cells can be specified explicitly: { "cells": [{ "ref": "B6", "value": {"formula": "=SUM(B2:B5)"} }] }
1067
+
1068
+ Optional fields per sheet: numberFormat, columnWidths, frozen, merges, autoFilter.
1069
+
1070
+ Not supported in v1: edit-in-place, charts, pivot tables, conditional formatting,
1071
+ images, macros. Use a sidecar instructions file for those for now.`);
1072
+ }
1073
+
1074
+ // Strip a string for value coercion: "42" → 42, "true" → true, "2026-04-27" → Date.
1075
+ function coerceMarkdownValue(c) {
1076
+ if (c == null || c === '') return null;
1077
+ // Backtick-fenced formula: `=SUM(A1:A10)`
1078
+ const fm = /^`\s*(=.+?)\s*`$/.exec(c);
1079
+ if (fm) return { formula: fm[1].replace(/^=/, '') };
1080
+ if (/^-?\d+$/.test(c)) return parseInt(c, 10);
1081
+ if (/^-?\d+\.\d+$/.test(c)) return parseFloat(c);
1082
+ if (/^(true|false)$/i.test(c)) return /^true$/i.test(c);
1083
+ if (/^\d{4}-\d{2}-\d{2}$/.test(c)) return new Date(c);
1084
+ return c.replace(/\\\|/g, '|');
1085
+ }
1086
+
1087
+ function parseMarkdownSpec(text) {
1088
+ // Walk the doc, accumulating lines per "## Heading" section. Each section
1089
+ // that contains a markdown table becomes a sheet.
1090
+ const sections = [];
1091
+ let currentName = null;
1092
+ let currentLines = [];
1093
+ for (const line of text.split('\n')) {
1094
+ const m = /^##\s+(.+?)\s*$/.exec(line);
1095
+ if (m) {
1096
+ if (currentLines.some(l => l.trim().startsWith('|'))) {
1097
+ sections.push({ name: currentName, lines: currentLines });
1098
+ }
1099
+ currentName = m[1];
1100
+ currentLines = [];
1101
+ } else {
1102
+ currentLines.push(line);
1103
+ }
1104
+ }
1105
+ if (currentLines.some(l => l.trim().startsWith('|'))) {
1106
+ sections.push({ name: currentName, lines: currentLines });
1107
+ }
1108
+ if (sections.length === 0) {
1109
+ throw new Error('No markdown table found in input');
1110
+ }
1111
+
1112
+ const sheets = sections.map(({ name, lines }, idx) => {
1113
+ const tableLines = lines
1114
+ .map(l => l.trim())
1115
+ .filter(l => l.startsWith('|') && l.endsWith('|'));
1116
+ if (tableLines.length < 2) {
1117
+ throw new Error(`Sheet "${name || `Sheet${idx+1}`}": no markdown table found`);
1118
+ }
1119
+ const cells = tableLines.map(l =>
1120
+ l.slice(1, -1).split(/(?<!\\)\|/).map(c => c.trim())
1121
+ );
1122
+ const sepIdx = cells.findIndex(row =>
1123
+ row.length > 0 && row.every(c => /^:?-+:?$/.test(c))
1124
+ );
1125
+ if (sepIdx < 1) throw new Error(`Sheet "${name || `Sheet${idx+1}`}": missing markdown header separator`);
1126
+ const headers = cells[sepIdx - 1];
1127
+ const rows = cells.slice(sepIdx + 1).map(row =>
1128
+ row.map(c => coerceMarkdownValue(c))
1129
+ );
1130
+ return { name: name || `Sheet${idx+1}`, headers, rows };
1131
+ });
1132
+
1133
+ return { sheets };
1134
+ }
1135
+
1136
+ function validateSpec(spec) {
1137
+ if (!spec || typeof spec !== 'object') throw new Error('Spec must be an object');
1138
+ // Single-sheet shortcut: top-level looks like a sheet → wrap.
1139
+ if ((Array.isArray(spec.rows) || Array.isArray(spec.cells)) && !Array.isArray(spec.sheets)) {
1140
+ spec = { sheets: [spec] };
1141
+ }
1142
+ // Array form (--json output for multi-sheet) → wrap.
1143
+ if (Array.isArray(spec)) {
1144
+ spec = { sheets: spec };
1145
+ }
1146
+ if (!Array.isArray(spec.sheets) || spec.sheets.length === 0) {
1147
+ throw new Error('Spec needs at least one sheet (top-level "sheets" array, or single-sheet "rows"/"cells")');
1148
+ }
1149
+ const names = new Set();
1150
+ for (const s of spec.sheets) {
1151
+ if (!s.name) throw new Error('Each sheet needs a "name"');
1152
+ if (names.has(s.name)) throw new Error(`Duplicate sheet name: "${s.name}"`);
1153
+ names.add(s.name);
1154
+ if (!Array.isArray(s.rows) && !Array.isArray(s.cells)) {
1155
+ throw new Error(`Sheet "${s.name}": needs "rows" array or "cells" array`);
1156
+ }
1157
+ if (Array.isArray(s.rows) && !Array.isArray(s.headers)) {
1158
+ // headers are optional; if absent, first row is treated as data.
1159
+ // No error.
1160
+ }
1161
+ }
1162
+ return spec;
1163
+ }
1164
+
1165
+ function trySimpleEval(formula) {
1166
+ const f = formula.replace(/^=/, '');
1167
+ const m = /^([A-Z]+)\(([^()]+)\)$/i.exec(f);
1168
+ if (!m) return null;
1169
+ const fn = m[1].toUpperCase();
1170
+ const args = m[2].split(',').map(s => parseFloat(s));
1171
+ if (!args.every(Number.isFinite)) return null;
1172
+ const fjs = lazyFormulaJs();
1173
+ if (typeof fjs[fn] !== 'function') return null;
1174
+ try { return fjs[fn](...args); } catch (_) { return null; }
1175
+ }
1176
+
1177
+ // JSON serialization turns Date instances into ISO strings, so on the way back
1178
+ // in from a spec we re-coerce ISO-shaped strings to Date — but only the shapes
1179
+ // that JSON.stringify(Date) actually produces. The signature of a Date-derived
1180
+ // string is the trailing Z (UTC); user-typed timestamp strings typically carry
1181
+ // a timezone offset like "-07:00", so we leave those alone.
1182
+ function coerceMaybeDate(v) {
1183
+ if (typeof v !== 'string') return v;
1184
+ // Pure date: "2019-01-01"
1185
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
1186
+ const d = new Date(v);
1187
+ return isNaN(d.getTime()) ? v : d;
1188
+ }
1189
+ // ISO with explicit UTC Z (what JSON.stringify(Date) produces for any Date)
1190
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(v)) {
1191
+ const d = new Date(v);
1192
+ return isNaN(d.getTime()) ? v : d;
1193
+ }
1194
+ return v;
1195
+ }
1196
+
1197
+ function buildCellValue(v, lossyOut) {
1198
+ if (v == null) return null;
1199
+ if (v instanceof Date) return v;
1200
+ if (typeof v === 'object') {
1201
+ if (v.formula) {
1202
+ const out = { formula: v.formula.replace(/^=/, '') };
1203
+ if (v.result !== undefined) out.result = coerceMaybeDate(v.result);
1204
+ else {
1205
+ const r = trySimpleEval(v.formula);
1206
+ if (r != null) out.result = r;
1207
+ }
1208
+ return out;
1209
+ }
1210
+ // Shared-formula followers: --json output emits these as
1211
+ // { sharedFormulaRef: "B5", result: <cached> }. ExcelJS can't reconstruct
1212
+ // a shared-formula follower from just a ref (it'd need the master expression
1213
+ // and relative-reference shifting). Pragmatic v1 behavior: degrade to the
1214
+ // cached result as a plain value. The cell's displayed value is preserved;
1215
+ // the formula link is lost.
1216
+ if (v.sharedFormulaRef || v.sharedFormula) {
1217
+ if (lossyOut) lossyOut.sharedFormula = (lossyOut.sharedFormula || 0) + 1;
1218
+ if (v.result === undefined) return null;
1219
+ return coerceMaybeDate(v.result);
1220
+ }
1221
+ if (v.hyperlink) {
1222
+ return { text: v.text || v.hyperlink, hyperlink: v.hyperlink };
1223
+ }
1224
+ return v;
1225
+ }
1226
+ // CRLF-in-string detection: ExcelJS normalizes \r\n → \n in shared-string
1227
+ // serialization. Visible content unchanged, but worth warning so users with
1228
+ // byte-exact pipelines aren't surprised.
1229
+ if (typeof v === 'string' && v.includes('\r') && lossyOut) {
1230
+ lossyOut.crlf = (lossyOut.crlf || 0) + 1;
1231
+ }
1232
+ return coerceMaybeDate(v);
1233
+ }
1234
+
1235
+ function applyCellStyle(cell, c) {
1236
+ if (c.numFmt) cell.numFmt = c.numFmt;
1237
+ if (c.bold || c.italic || c.color) {
1238
+ cell.font = {};
1239
+ if (c.bold) cell.font.bold = true;
1240
+ if (c.italic) cell.font.italic = true;
1241
+ if (c.color) cell.font.color = { argb: c.color };
1242
+ }
1243
+ if (c.fill) {
1244
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: c.fill } };
1245
+ }
1246
+ if (c.align) {
1247
+ cell.alignment = { horizontal: c.align };
1248
+ }
1249
+ }
1250
+
1251
+ function applyNumberFormat(ws, ref, fmt) {
1252
+ // "A:A" or "A:D" — whole columns
1253
+ const colMatch = /^([A-Z]+):([A-Z]+)$/i.exec(ref);
1254
+ if (colMatch) {
1255
+ const c1 = colNum(colMatch[1]);
1256
+ const c2 = colNum(colMatch[2]);
1257
+ for (let c = c1; c <= c2; c++) ws.getColumn(c).numFmt = fmt;
1258
+ return;
1259
+ }
1260
+ if (ref.includes(':')) {
1261
+ const r = parseRange(ref);
1262
+ for (let row = r.startRow; row <= r.endRow; row++) {
1263
+ for (let col = r.startCol; col <= r.endCol; col++) {
1264
+ ws.getCell(`${colLetter(col)}${row}`).numFmt = fmt;
1265
+ }
1266
+ }
1267
+ } else {
1268
+ ws.getCell(ref).numFmt = fmt;
1269
+ }
1270
+ }
1271
+
1272
+ function buildWorkbook(spec) {
1273
+ const wb = new ExcelJS.Workbook();
1274
+ const warnings = []; // [{type, sheet, ref}, ...]
1275
+
1276
+ function track(sheetName, ref, lossy) {
1277
+ if (lossy.sharedFormula) warnings.push({ type: 'sharedFormula', sheet: sheetName, ref });
1278
+ if (lossy.crlf) warnings.push({ type: 'crlf', sheet: sheetName, ref });
1279
+ }
1280
+
1281
+ for (const sheet of spec.sheets) {
1282
+ const ws = wb.addWorksheet(sheet.name);
1283
+
1284
+ if (sheet.frozen) {
1285
+ ws.views = [{
1286
+ state: 'frozen',
1287
+ xSplit: sheet.frozen.colSplit ?? sheet.frozen.xSplit ?? 0,
1288
+ ySplit: sheet.frozen.rowSplit ?? sheet.frozen.ySplit ?? 0,
1289
+ }];
1290
+ }
1291
+
1292
+ if (sheet.columnWidths && typeof sheet.columnWidths === 'object') {
1293
+ for (const [letter, width] of Object.entries(sheet.columnWidths)) {
1294
+ try { ws.getColumn(colNum(letter)).width = width; } catch (_) {}
1295
+ }
1296
+ }
1297
+
1298
+ if (Array.isArray(sheet.cells)) {
1299
+ // Per-cell mode (round-trip from --json). cells: [{ref, value, ...style}, ...]
1300
+ for (const c of sheet.cells) {
1301
+ if (!c.ref) continue;
1302
+ const cell = ws.getCell(c.ref);
1303
+ const lossy = {};
1304
+ cell.value = buildCellValue(c.value, lossy);
1305
+ track(sheet.name, c.ref, lossy);
1306
+ applyCellStyle(cell, c);
1307
+ }
1308
+ } else {
1309
+ // Tabular mode (markdown / simple JSON). headers (optional) + rows.
1310
+ let rowIdx = 1;
1311
+ if (Array.isArray(sheet.headers) && sheet.headers.length > 0) {
1312
+ const hdrRow = ws.getRow(rowIdx);
1313
+ sheet.headers.forEach((h, i) => {
1314
+ const cell = hdrRow.getCell(i + 1);
1315
+ cell.value = h;
1316
+ cell.font = { bold: true };
1317
+ });
1318
+ rowIdx++;
1319
+ }
1320
+ for (const r of sheet.rows) {
1321
+ const row = ws.getRow(rowIdx);
1322
+ if (Array.isArray(r)) {
1323
+ r.forEach((v, i) => {
1324
+ const lossy = {};
1325
+ row.getCell(i + 1).value = buildCellValue(v, lossy);
1326
+ if (lossy.sharedFormula || lossy.crlf) {
1327
+ track(sheet.name, `${colLetter(i+1)}${rowIdx}`, lossy);
1328
+ }
1329
+ });
1330
+ } else if (r && typeof r === 'object') {
1331
+ // Object form: { col1: val, col2: val }, keyed by header name.
1332
+ if (Array.isArray(sheet.headers)) {
1333
+ sheet.headers.forEach((h, i) => {
1334
+ if (r[h] !== undefined) {
1335
+ const lossy = {};
1336
+ row.getCell(i + 1).value = buildCellValue(r[h], lossy);
1337
+ if (lossy.sharedFormula || lossy.crlf) {
1338
+ track(sheet.name, `${colLetter(i+1)}${rowIdx}`, lossy);
1339
+ }
1340
+ }
1341
+ });
1342
+ }
1343
+ }
1344
+ rowIdx++;
1345
+ }
1346
+ }
1347
+
1348
+ if (sheet.numberFormat && typeof sheet.numberFormat === 'object') {
1349
+ for (const [ref, fmt] of Object.entries(sheet.numberFormat)) {
1350
+ try { applyNumberFormat(ws, ref, fmt); } catch (_) {}
1351
+ }
1352
+ }
1353
+
1354
+ if (Array.isArray(sheet.merges)) {
1355
+ for (const m of sheet.merges) {
1356
+ try { ws.mergeCells(m); } catch (_) {}
1357
+ }
1358
+ }
1359
+
1360
+ if (sheet.autoFilter) {
1361
+ ws.autoFilter = sheet.autoFilter;
1362
+ }
1363
+
1364
+ // Sheet-level named ranges (the shape --json output produces:
1365
+ // [{name, ranges: ["Sheet1!$A$1:$D$10"]}, ...])
1366
+ if (Array.isArray(sheet.namedRanges)) {
1367
+ for (const nr of sheet.namedRanges) {
1368
+ if (!nr.name || !Array.isArray(nr.ranges)) continue;
1369
+ for (const ref of nr.ranges) {
1370
+ try { wb.definedNames.add(ref, nr.name); } catch (_) {}
1371
+ }
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ // Workbook-level named ranges (concise spec form: { "Totals": "Sheet1!B2:C5" })
1377
+ if (spec.namedRanges && typeof spec.namedRanges === 'object' && !Array.isArray(spec.namedRanges)) {
1378
+ for (const [name, ref] of Object.entries(spec.namedRanges)) {
1379
+ try { wb.definedNames.add(ref, name); } catch (_) {}
1380
+ }
1381
+ }
1382
+ // Workbook-level array form (also from --json)
1383
+ if (Array.isArray(spec.namedRanges)) {
1384
+ for (const nr of spec.namedRanges) {
1385
+ if (!nr.name || !Array.isArray(nr.ranges)) continue;
1386
+ for (const ref of nr.ranges) {
1387
+ try { wb.definedNames.add(ref, nr.name); } catch (_) {}
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ return { wb, warnings };
1393
+ }
1394
+
1395
+ // Per-issue review templates. Each entry follows the "supervisor leaves a
1396
+ // review note" shape: what happened, what we did, the risk, the tradeoff, and
1397
+ // how to override. Keeps the user in the decision seat.
1398
+ const REPORT_REVIEWS = {
1399
+ sharedFormula: {
1400
+ title: 'Shared formula degradation',
1401
+ whatHappened:
1402
+ "The source file used Excel's shared-formula optimization — one master cell carries the formula, follower cells reference the master. ExcelJS cannot reconstruct that link in the output file.",
1403
+ whatWeDid:
1404
+ 'Each follower cell was replaced with its cached numeric value. You will see the same numbers in Excel as before; the formula link itself is gone.',
1405
+ risk:
1406
+ 'If you edit any cell the original formula depended on, the previously-shared cells will not recalculate — they are now hardcoded numbers, not formulas.',
1407
+ tradeoff:
1408
+ 'Smaller file, but the spreadsheet is "frozen": adding rows or changing inputs will not propagate the way they used to.',
1409
+ alternative:
1410
+ 'Rerun with --fix-shared-formulas=expand (planned for v1.5). Each follower becomes an explicit per-cell formula — slightly larger file, but each cell recalculates independently like hand-written formulas. Closest behavior to the original source.',
1411
+ },
1412
+ crlf: {
1413
+ title: 'CRLF → LF line-ending normalization',
1414
+ whatHappened:
1415
+ 'The source file had Windows-style CRLF line endings (\\r\\n) inside cell text. ExcelJS normalizes these to Unix-style LF (\\n) when writing shared strings.',
1416
+ whatWeDid:
1417
+ 'Each affected cell\'s text now uses LF instead of CRLF. Visible content is identical — Excel, Numbers, and LibreOffice render both the same way.',
1418
+ risk:
1419
+ 'No risk to the spreadsheet content itself. Only matters if a downstream tool does byte-exact comparison or specifically processes \\r\\n (e.g., greps for Windows-encoded text).',
1420
+ tradeoff:
1421
+ 'None visible in spreadsheet apps. The output is also marginally smaller.',
1422
+ alternative:
1423
+ 'If your pipeline requires CRLF preservation, pre-process source strings to substitute a placeholder before extracting --json, then restore after writing. Or simply ignore — this is the most cosmetic of the round-trip artifacts.',
1424
+ },
1425
+ };
1426
+
1427
+ // Add a "_xlsx-for-ai" tab to the workbook with a review-style report of any
1428
+ // round-trip lossy events. Embedded in the file (not just stderr) so the
1429
+ // feedback travels with the workbook. Each issue type gets a full review note
1430
+ // (what happened, what we did, risk, tradeoff, alternative) so the user can
1431
+ // understand the decisions and override if they prefer different behavior.
1432
+ function addReportSheet(wb, warnings) {
1433
+ if (warnings.length === 0) return;
1434
+
1435
+ const ws = wb.addWorksheet('_xlsx-for-ai');
1436
+
1437
+ // Header rows
1438
+ ws.getCell('A1').value = 'xlsx-for-ai write report';
1439
+ ws.getCell('A1').font = { bold: true, size: 14 };
1440
+ ws.mergeCells('A1:D1');
1441
+
1442
+ ws.getCell('A2').value = `Generated ${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`;
1443
+ ws.getCell('A2').font = { italic: true, color: { argb: 'FF666666' } };
1444
+ ws.mergeCells('A2:D2');
1445
+
1446
+ ws.getCell('A3').value =
1447
+ 'This file passed through xlsx-for-ai write. The sections below describe what changed during the round-trip, why, and how to override if you want different behavior. Cell values you see in the rest of the workbook are correct — these notes describe structural changes (formulas, line endings, etc.) that may matter for future edits.';
1448
+ ws.getCell('A3').font = { italic: true, color: { argb: 'FF666666' } };
1449
+ ws.getCell('A3').alignment = { wrapText: true, vertical: 'top' };
1450
+ ws.mergeCells('A3:D3');
1451
+ ws.getRow(3).height = 60;
1452
+
1453
+ // Group warnings by type
1454
+ const byType = {};
1455
+ for (const w of warnings) (byType[w.type] = byType[w.type] || []).push(w);
1456
+
1457
+ let r = 5;
1458
+
1459
+ // Per-issue review block
1460
+ for (const [type, group] of Object.entries(byType)) {
1461
+ const review = REPORT_REVIEWS[type] || {
1462
+ title: type,
1463
+ whatHappened: 'Unspecified round-trip change.',
1464
+ whatWeDid: '(no template available)',
1465
+ risk: '(unknown)',
1466
+ tradeoff: '(unknown)',
1467
+ alternative: '(none)',
1468
+ };
1469
+
1470
+ // Issue heading bar
1471
+ ws.getCell(`A${r}`).value = `Issue: ${review.title} (${group.length} cell${group.length === 1 ? '' : 's'})`;
1472
+ ws.getCell(`A${r}`).font = { bold: true, size: 12 };
1473
+ ws.getCell(`A${r}`).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE7F0F8' } };
1474
+ ws.mergeCells(`A${r}:D${r}`);
1475
+ r++;
1476
+
1477
+ const addProse = (label, body) => {
1478
+ ws.getCell(`A${r}`).value = label;
1479
+ ws.getCell(`A${r}`).font = { bold: true };
1480
+ ws.getCell(`A${r}`).alignment = { vertical: 'top', wrapText: true };
1481
+ ws.getCell(`B${r}`).value = body;
1482
+ ws.getCell(`B${r}`).alignment = { wrapText: true, vertical: 'top' };
1483
+ ws.mergeCells(`B${r}:D${r}`);
1484
+ // Approximate height: ~6 chars per Excel "row unit" given an 80-char column,
1485
+ // 15px per row unit baseline. Capped at a reasonable max.
1486
+ const lines = Math.max(2, Math.ceil(body.length / 95));
1487
+ ws.getRow(r).height = Math.min(lines * 15, 120);
1488
+ r++;
1489
+ };
1490
+
1491
+ addProse('What happened', review.whatHappened);
1492
+ addProse('What we did', review.whatWeDid);
1493
+ addProse('Risk', review.risk);
1494
+ addProse('Tradeoff', review.tradeoff);
1495
+ addProse('Alternative', review.alternative);
1496
+
1497
+ // Compact "affected cells" summary
1498
+ const cellList = group.map(w => `${w.sheet}!${w.ref}`);
1499
+ const cellSummary = cellList.length <= 10
1500
+ ? cellList.join(', ')
1501
+ : `${cellList.slice(0, 10).join(', ')}, ... and ${cellList.length - 10} more (full list at the bottom of this sheet)`;
1502
+ addProse('Affected cells', cellSummary);
1503
+
1504
+ // Spacer row between issue blocks
1505
+ r++;
1506
+ }
1507
+
1508
+ // Full detail table
1509
+ ws.getCell(`A${r}`).value = 'Full detail (one row per affected cell)';
1510
+ ws.getCell(`A${r}`).font = { bold: true, size: 12 };
1511
+ ws.mergeCells(`A${r}:D${r}`);
1512
+ r++;
1513
+
1514
+ ws.getCell(`A${r}`).value = 'Sheet';
1515
+ ws.getCell(`B${r}`).value = 'Cell';
1516
+ ws.getCell(`C${r}`).value = 'Issue type';
1517
+ ws.getCell(`D${r}`).value = 'Title';
1518
+ ['A','B','C','D'].forEach(c => {
1519
+ ws.getCell(`${c}${r}`).font = { bold: true };
1520
+ ws.getCell(`${c}${r}`).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEEEEE' } };
1521
+ });
1522
+ r++;
1523
+
1524
+ const MAX_DETAIL = 1000;
1525
+ const detailRows = warnings.slice(0, MAX_DETAIL);
1526
+ for (const w of detailRows) {
1527
+ ws.getCell(`A${r}`).value = w.sheet;
1528
+ ws.getCell(`B${r}`).value = w.ref;
1529
+ ws.getCell(`C${r}`).value = w.type;
1530
+ ws.getCell(`D${r}`).value = (REPORT_REVIEWS[w.type] && REPORT_REVIEWS[w.type].title) || w.type;
1531
+ r++;
1532
+ }
1533
+ if (warnings.length > MAX_DETAIL) {
1534
+ ws.getCell(`A${r}`).value = `... and ${warnings.length - MAX_DETAIL} more (totals shown in the issue blocks above)`;
1535
+ ws.getCell(`A${r}`).font = { italic: true };
1536
+ ws.mergeCells(`A${r}:D${r}`);
1537
+ }
1538
+
1539
+ // Column widths
1540
+ ws.getColumn(1).width = 18;
1541
+ ws.getColumn(2).width = 12;
1542
+ ws.getColumn(3).width = 18;
1543
+ ws.getColumn(4).width = 80;
1544
+ }
1545
+
1546
+ function readStdinAll() {
1547
+ return new Promise((resolve, reject) => {
1548
+ let data = '';
1549
+ process.stdin.setEncoding('utf8');
1550
+ process.stdin.on('data', chunk => { data += chunk; });
1551
+ process.stdin.on('end', () => resolve(data));
1552
+ process.stdin.on('error', reject);
1553
+ });
1554
+ }
1555
+
1556
+ async function readSpecText(specPath) {
1557
+ if (specPath === '-') return readStdinAll();
1558
+ if (!fs.existsSync(specPath)) {
1559
+ throw new Error(`Spec file not found: ${specPath}`);
1560
+ }
1561
+ return fs.readFileSync(specPath, 'utf8');
1562
+ }
1563
+
1564
+ async function loadSpec(specPath) {
1565
+ const text = await readSpecText(specPath);
1566
+ const trimmed = text.trim();
1567
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
1568
+ let parsed;
1569
+ try { parsed = JSON.parse(trimmed); }
1570
+ catch (e) { throw new Error(`Spec is not valid JSON: ${e.message}`); }
1571
+ return parsed;
1572
+ }
1573
+ return parseMarkdownSpec(text);
1574
+ }
1575
+
1576
+ async function mainWrite(argv) {
1577
+ const opts = parseWriteArgs(argv);
1578
+ if (opts.help) { printWriteHelp(); process.exit(0); }
1579
+ if (opts.positional.length < 1) { printWriteHelp(); process.exit(1); }
1580
+
1581
+ const specPath = opts.positional[0];
1582
+
1583
+ let spec;
1584
+ try {
1585
+ spec = await loadSpec(specPath);
1586
+ spec = validateSpec(spec);
1587
+ } catch (e) {
1588
+ console.error(`Spec error: ${e.message}`);
1589
+ process.exit(1);
1590
+ }
1591
+
1592
+ let wb, warnings;
1593
+ try {
1594
+ ({ wb, warnings } = buildWorkbook(spec));
1595
+ } catch (e) {
1596
+ console.error(`Build error: ${e.message}`);
1597
+ process.exit(1);
1598
+ }
1599
+
1600
+ // Embed a review-style report tab in the file when there are round-trip
1601
+ // warnings, so the feedback travels with the workbook for the human or agent
1602
+ // that opens it. `--no-report` suppresses for pipelines that don't want the
1603
+ // extra sheet (e.g. round-trip CI tests).
1604
+ if (!opts.noReport) {
1605
+ addReportSheet(wb, warnings);
1606
+ }
1607
+
1608
+ let outPath = opts.output;
1609
+ if (!outPath) {
1610
+ if (specPath === '-') outPath = 'output.xlsx';
1611
+ else outPath = path.basename(specPath, path.extname(specPath)) + '.xlsx';
1612
+ }
1613
+ outPath = path.resolve(outPath);
1614
+
1615
+ try {
1616
+ await wb.xlsx.writeFile(outPath);
1617
+ } catch (e) {
1618
+ console.error(`Write error: ${e.message}`);
1619
+ process.exit(1);
1620
+ }
1621
+ console.log(outPath);
1622
+ if (warnings.length > 0) {
1623
+ console.error(`note: ${warnings.length} round-trip warning(s) written to '_xlsx-for-ai' sheet in the output.`);
1624
+ }
1625
+ }
1626
+
1627
+ // ---------------------------------------------------------------------------
1628
+ // Main
1629
+ // ---------------------------------------------------------------------------
1630
+
991
1631
  async function main() {
992
- const opts = parseArgs(process.argv.slice(2));
1632
+ const argv = process.argv.slice(2);
1633
+
1634
+ // Sub-command dispatch
1635
+ if (argv[0] === 'write') return mainWrite(argv.slice(1));
1636
+
1637
+ const opts = parseArgs(argv);
993
1638
 
994
1639
  if (opts.help) { printHelp(); process.exit(0); }
995
1640
  if (opts.positional.length < 1) { printHelp(); process.exit(1); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xlsx-for-ai",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "CLI that converts .xlsx files into rich text or JSON dumps that AI coding agents (Claude, Cursor, Copilot, ChatGPT, etc.) can read — preserving values, formulas, formatting, colors, column widths, frozen panes, named ranges, tables, and more.",
5
5
  "main": "index.js",
6
6
  "bin": {