xlsx-for-ai 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 senoff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # xlsx-for-ai
2
+
3
+ Converts `.xlsx` files into rich text or JSON dumps that AI coding agents can actually read.
4
+
5
+ AI tools — Claude, Cursor, Copilot, ChatGPT, and other LLM coding agents — can read text files but **not** `.xlsx` binaries. This CLI bridges the gap. It extracts everything a human would see in Excel and writes it to a plain text file (or structured JSON):
6
+
7
+ - **Values** — strings, numbers, dates
8
+ - **Formulas** — the actual formula expression, plus shared-formula references
9
+ - **Formatting** — bold, italic, font colors, background fills
10
+ - **Number formats** — percentages, currency, custom patterns
11
+ - **Layout** — column widths, frozen panes, merged cells, alignment
12
+ - **Hyperlinks** — URLs embedded in cells
13
+ - **Comments / notes** — cell annotations
14
+ - **Named ranges** — workbook-defined names and their references
15
+ - **Hidden rows & columns** — flagged so the AI knows data is suppressed
16
+ - **Data validation** — dropdown lists, numeric constraints
17
+ - **Tables** — Excel Table objects with their names and column headers
18
+ - **Images & charts** — existence and position noted (content not rendered)
19
+ - **Auto-filters** — active filter ranges
20
+ - **Print areas** — defined print regions
21
+
22
+ > Previously published as **`cursor-reads-xlsx`**. The old name still works as an alias on the CLI, but please install the new package: `npm install -g xlsx-for-ai`.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install -g xlsx-for-ai
28
+ ```
29
+
30
+ Or run directly with npx (no install needed):
31
+
32
+ ```bash
33
+ npx xlsx-for-ai budget.xlsx
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # Dump all sheets
40
+ npx xlsx-for-ai data.xlsx
41
+
42
+ # Dump a specific sheet
43
+ npx xlsx-for-ai data.xlsx "Sheet1"
44
+
45
+ # List sheet names and dimensions without dumping
46
+ npx xlsx-for-ai data.xlsx --list-sheets
47
+
48
+ # Print to stdout instead of writing files
49
+ npx xlsx-for-ai data.xlsx --stdout
50
+
51
+ # Limit to first 200 rows per sheet (useful for huge files)
52
+ npx xlsx-for-ai data.xlsx --max-rows 200
53
+
54
+ # Limit to first 8 columns (useful for very wide sheets)
55
+ npx xlsx-for-ai data.xlsx --max-cols 8
56
+
57
+ # Suppress noisy default tags (default text colors, white fills, etc.)
58
+ npx xlsx-for-ai data.xlsx --stdout --compact
59
+
60
+ # Emit structured JSON (one entry per cell) instead of the text dump
61
+ npx xlsx-for-ai data.xlsx --json --stdout > out.json
62
+
63
+ # Combine flags
64
+ npx xlsx-for-ai data.xlsx "Sheet1" --stdout --max-rows 50 --compact
65
+ ```
66
+
67
+ ### Options
68
+
69
+ | Flag | Description |
70
+ |------|-------------|
71
+ | `--list-sheets` | Print sheet names, row/column counts, and visibility — then exit |
72
+ | `--stdout` | Print output to stdout instead of writing `.txt` files |
73
+ | `--json` | Emit structured JSON (one object per cell with value/formula/format/style) |
74
+ | `--compact` | Suppress noisy default tags (default text color, white fill, etc.) — reduces token usage for AI agents |
75
+ | `--max-rows N` | Cap output at the first N rows per sheet |
76
+ | `--max-cols N` | Cap output at the first N columns per sheet |
77
+ | `-h`, `--help` | Show help message |
78
+
79
+ Output files are written to `.xlsx-read/` in the current working directory.
80
+ Each sheet produces a file named `<filename>--<sheetname>.txt`.
81
+ The path(s) are printed to stdout so your agent knows where to read.
82
+
83
+ ## Output Format
84
+
85
+ ### Text dump (default)
86
+
87
+ ```
88
+ === Sheet: Sales ===
89
+ Frozen: row 1, col 0
90
+ Columns: A(12) B(20) C(15) D(10)
91
+ Auto-filter: A1:D20
92
+ Named ranges:
93
+ Totals: Sales!$D$2:$D$20
94
+ Table: "SalesTable" A1:D20 — columns: Region, Q1, Q2, Total
95
+
96
+ --- Row 1 [bold] ---
97
+ A1: "Region" [bold]
98
+ B1: "Q1" [bold] [align:center]
99
+ C1: "Q2" [bold] [align:center]
100
+ D1: "Total" [bold] [align:center]
101
+ --- Row 2 ---
102
+ A2: "North" [link: https://example.com/north]
103
+ B2: 14500 [numFmt: #,##0]
104
+ C2: 17200 [numFmt: #,##0]
105
+ D2: 31700 [formula: =B2+C2] [numFmt: #,##0] [note: Includes returns]
106
+ --- Row 3 ---
107
+ A3: "South" [fill:FFFFFF00]
108
+ B3: 9800 [numFmt: #,##0] [validation: list [North,South,East,West]]
109
+ C3: 11050 [numFmt: #,##0]
110
+ D3: 20850 [shared formula ref: D2] [numFmt: #,##0]
111
+ --- Row 4 (empty) [hidden] ---
112
+ ```
113
+
114
+ ### JSON dump (`--json`)
115
+
116
+ ```json
117
+ {
118
+ "name": "Sales",
119
+ "rowCount": 4,
120
+ "columnCount": 4,
121
+ "frozen": { "rowSplit": 1, "colSplit": 0 },
122
+ "columns": [{ "letter": "A", "width": 12 }, ...],
123
+ "namedRanges": [{ "name": "Totals", "ranges": ["Sales!$D$2:$D$20"] }],
124
+ "tables": [{ "name": "SalesTable", "ref": "A1:D20", "columns": ["Region", "Q1", "Q2", "Total"] }],
125
+ "cells": [
126
+ { "ref": "D2", "row": 2, "col": 4, "value": { "formula": "B2+C2", "result": 31700 }, "numFmt": "#,##0" },
127
+ { "ref": "D3", "row": 3, "col": 4, "value": { "sharedFormulaRef": "D2", "result": 20850 }, "numFmt": "#,##0" }
128
+ ]
129
+ }
130
+ ```
131
+
132
+ ### Sheet Metadata
133
+
134
+ | Line | Meaning |
135
+ |------|---------|
136
+ | `Frozen: row 1, col 2` | Frozen panes position |
137
+ | `Columns: A(12) B(20)` | Column widths (Excel character units) |
138
+ | `Hidden columns: E, F` | Columns hidden in the spreadsheet |
139
+ | `Merged: A1:B1` | Merged cell ranges |
140
+ | `Auto-filter: A1:D20` | Active auto-filter range |
141
+ | `Print area: A1:D50` | Defined print area |
142
+ | `Named ranges:` | Workbook-defined names referencing this sheet |
143
+ | `Table: "Name" A1:D20` | Excel Table objects with column headers |
144
+ | `Image: A1 to C5` | Embedded image position |
145
+
146
+ ### Cell Tags
147
+
148
+ | Tag | Meaning |
149
+ |-----|---------|
150
+ | `[formula: =SUM(A1:A10)]` | Cell contains this formula (master cell) |
151
+ | `[shared formula ref: D2]` | Cell shares D2's formula (Excel "shared formula" — common when you drag-fill) |
152
+ | `[numFmt: 0.00%]` | Number format (when not "General") |
153
+ | `[bold]` | Bold font |
154
+ | `[italic]` | Italic font |
155
+ | `[color:FF8B0000]` | Font color (ARGB hex) |
156
+ | `[fill:FFFFFF00]` | Cell background color (ARGB hex) |
157
+ | `[align:center]` | Horizontal alignment (when not default) |
158
+ | `[link: https://...]` | Hyperlink URL |
159
+ | `[note: ...]` | Cell comment or note text |
160
+ | `[validation: list [...]]` | Data validation (dropdown values or constraints) |
161
+ | `[hidden]` | Row is hidden in the spreadsheet |
162
+
163
+ ### `--list-sheets` Output
164
+
165
+ ```
166
+ Sales 250 rows × 12 cols
167
+ Config 15 rows × 4 cols
168
+ Archive 1200 rows × 8 cols [hidden]
169
+ ```
170
+
171
+ ## Cursor / Claude / Agent Rule Template
172
+
173
+ Copy the included rule template into your project so your AI agent automatically uses this tool when it encounters `.xlsx` files:
174
+
175
+ ```bash
176
+ mkdir -p .cursor/rules
177
+ cp node_modules/xlsx-for-ai/cursor-rule-template/read-xlsx.mdc .cursor/rules/
178
+ ```
179
+
180
+ Or fetch it directly:
181
+
182
+ ```bash
183
+ mkdir -p .cursor/rules
184
+ curl -o .cursor/rules/read-xlsx.mdc https://raw.githubusercontent.com/senoff/xlsx-for-ai/main/cursor-rule-template/read-xlsx.mdc
185
+ ```
186
+
187
+ The same rule works for Claude Code (`.claude/rules/`), Copilot (`.github/copilot-instructions.md`), or any other agent — just adjust the path.
188
+
189
+ ## Why This Exists
190
+
191
+ Spreadsheets are everywhere in real projects — financial models, data exports, config files, tax estimates. AI coding agents choke on binary formats. This tool makes spreadsheets legible to AI with zero information loss, including the tricky bits like shared formulas, named ranges, and merged cells that other tools drop.
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,50 @@
1
+ ---
2
+ description: Reading .xlsx spreadsheet files
3
+ globs:
4
+ alwaysApply: true
5
+ ---
6
+
7
+ # Reading .xlsx Files
8
+
9
+ The Read tool cannot open `.xlsx` files directly. When you need to inspect a spreadsheet:
10
+
11
+ 1. **Run the converter** from the project root:
12
+ ```bash
13
+ npx xlsx-for-ai <path-to-file.xlsx> [sheetName]
14
+ ```
15
+ - If `sheetName` is omitted, all sheets are dumped.
16
+ - Output is written to `.xlsx-read/<filename>--<sheet>.txt` (project root).
17
+ - The script prints the output path(s) to stdout.
18
+
19
+ 2. **Read the output file** with the Read tool. It contains:
20
+ - Sheet metadata (frozen panes, column widths, merged cells, auto-filters, print areas)
21
+ - Named ranges referencing the sheet
22
+ - Table definitions (name, range, columns)
23
+ - Image positions
24
+ - Every row with its cells, showing:
25
+ - **Value** — always present
26
+ - **Formula** — `[formula: =SUM(A1:A10)]` (master cell) or `[shared formula ref: D2]` (drag-fill follow-up)
27
+ - **Number format** — `[numFmt: 0.00%]` if not "General"
28
+ - **Font** — `[bold]`, `[italic]`, `[color:FF8B0000]`
29
+ - **Fill** — `[fill:FFFFFF00]` if background color set
30
+ - **Alignment** — `[align:center]` if non-default
31
+ - **Hyperlink** — `[link: https://...]` if the cell contains a URL
32
+ - **Comment** — `[note: ...]` if the cell has a comment or note
33
+ - **Validation** — `[validation: list [...]]` if the cell has data validation
34
+ - **Hidden** — `[hidden]` on the row header if the row is hidden
35
+ - Empty cells are omitted; empty rows show `(empty)`.
36
+
37
+ 3. **Do not ask the user** before running this. Just run it when you need to see an `.xlsx` file.
38
+
39
+ ## Useful flags
40
+ - `--list-sheets` — list sheet names and dimensions without dumping content
41
+ - `--stdout` — print directly to stdout instead of writing files
42
+ - `--json` — emit structured JSON (one object per cell) for easier programmatic use
43
+ - `--compact` — suppress noisy default tags (default text colors, white fills) to reduce token usage
44
+ - `--max-rows N` — limit output to first N rows (use for large sheets)
45
+ - `--max-cols N` — limit output to first N columns (use for very wide sheets)
46
+
47
+ ## Important
48
+ - Output goes to `.xlsx-read/` in the current working directory — make sure this directory is in your `.gitignore`.
49
+ - For large files, use `--max-rows`, `--max-cols`, or request a single sheet to keep output manageable.
50
+ - The package was previously named `cursor-reads-xlsx` — that command name still works as an alias.
package/index.js ADDED
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const ExcelJS = require('exceljs');
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Argument parsing
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function parseArgs(argv) {
12
+ const opts = {
13
+ positional: [],
14
+ listSheets: false,
15
+ stdout: false,
16
+ json: false,
17
+ compact: false,
18
+ maxRows: null,
19
+ maxCols: null,
20
+ help: false,
21
+ };
22
+ let i = 0;
23
+ while (i < argv.length) {
24
+ const arg = argv[i];
25
+ if (arg === '--list-sheets') opts.listSheets = true;
26
+ else if (arg === '--stdout') opts.stdout = true;
27
+ else if (arg === '--json') opts.json = true;
28
+ else if (arg === '--compact') opts.compact = true;
29
+ else if (arg === '--max-rows') { opts.maxRows = parseInt(argv[++i], 10); }
30
+ else if (arg === '--max-cols') { opts.maxCols = parseInt(argv[++i], 10); }
31
+ else if (arg === '-h' || arg === '--help') opts.help = true;
32
+ else opts.positional.push(arg);
33
+ i++;
34
+ }
35
+ return opts;
36
+ }
37
+
38
+ function printHelp() {
39
+ console.log(`Usage: npx xlsx-for-ai <file.xlsx> [sheetName] [options]
40
+
41
+ Converts .xlsx to rich text (or JSON) that AI coding agents can read.
42
+
43
+ Options:
44
+ --list-sheets List sheet names, dimensions, and visibility then exit
45
+ --stdout Print output to stdout instead of writing files
46
+ --json Emit structured JSON instead of the human-readable text dump
47
+ (one object per cell with value/formula/format/style)
48
+ --compact Suppress noisy default tags (default text color, default font,
49
+ General number format, etc.) to reduce token usage
50
+ --max-rows N Limit output to the first N rows per sheet
51
+ --max-cols N Limit output to the first N columns per sheet
52
+ -h, --help Show this help message
53
+
54
+ Examples:
55
+ npx xlsx-for-ai data.xlsx
56
+ npx xlsx-for-ai data.xlsx "Sheet1"
57
+ npx xlsx-for-ai data.xlsx --list-sheets
58
+ npx xlsx-for-ai data.xlsx --stdout --max-rows 100
59
+ npx xlsx-for-ai data.xlsx --stdout --compact
60
+ npx xlsx-for-ai data.xlsx --json --stdout > out.json
61
+
62
+ Note: this package was previously published as 'cursor-reads-xlsx';
63
+ that command name still works as an alias.`);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function colLetter(n) {
71
+ let s = '';
72
+ for (; n > 0; n = Math.floor((n - 1) / 26))
73
+ s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
74
+ return s;
75
+ }
76
+
77
+ // Colors that are visually indistinguishable from the default Excel text (near-black).
78
+ // In --compact mode we suppress these to reduce token noise.
79
+ const DEFAULT_TEXT_COLORS = new Set([
80
+ 'FF000000', // pure black
81
+ 'FF1F1F1F', // dark gray (Excel auto-text on some themes)
82
+ 'FF222120', // dark gray variant
83
+ 'FF333333',
84
+ ]);
85
+
86
+ function isDefaultTextColor(argb) {
87
+ return argb && DEFAULT_TEXT_COLORS.has(argb.toUpperCase());
88
+ }
89
+
90
+ function describeFill(fill, compact) {
91
+ if (!fill || (fill.type === 'pattern' && fill.pattern === 'none')) return null;
92
+ if (fill.type === 'pattern' && fill.fgColor?.argb) {
93
+ // White / no-fill patterns are noise in compact mode
94
+ if (compact && /^FF?FFFFFF$/i.test(fill.fgColor.argb)) return null;
95
+ return `fill:${fill.fgColor.argb}`;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function describeFont(font, compact) {
101
+ const parts = [];
102
+ if (font?.bold) parts.push('bold');
103
+ if (font?.italic) parts.push('italic');
104
+ if (font?.color?.argb) {
105
+ if (!(compact && isDefaultTextColor(font.color.argb))) {
106
+ parts.push(`color:${font.color.argb}`);
107
+ }
108
+ }
109
+ return parts;
110
+ }
111
+
112
+ function formatValue(v) {
113
+ if (v == null) return '""';
114
+ if (v instanceof Date) return `"${v.toISOString().slice(0, 10)}"`;
115
+ if (typeof v === 'object' && v.richText) {
116
+ return `"${v.richText.map(r => r.text).join('')}"`;
117
+ }
118
+ if (typeof v === 'object' && v.hyperlink) {
119
+ return `"${v.text || v.hyperlink}"`;
120
+ }
121
+ // Formula cells: 'formula' on master, 'sharedFormula' on follow-ups, 'result' is the computed value
122
+ if (typeof v === 'object' && (v.formula || v.sharedFormula)) {
123
+ const result = v.result;
124
+ if (result == null) return '""';
125
+ // Result may itself be a rich object (error, richText, etc.)
126
+ if (typeof result === 'object') {
127
+ if (result.error) return `"#${result.error}"`;
128
+ if (result.richText) return `"${result.richText.map(r => r.text).join('')}"`;
129
+ return JSON.stringify(result);
130
+ }
131
+ if (typeof result === 'string') return `"${result}"`;
132
+ return String(result);
133
+ }
134
+ if (typeof v === 'object' && v.error) return `"#${v.error}"`;
135
+ if (typeof v === 'string') return `"${v}"`;
136
+ return String(v);
137
+ }
138
+
139
+ function describeNote(note) {
140
+ if (!note) return null;
141
+ if (typeof note === 'string') return note;
142
+ if (note.texts) {
143
+ return note.texts.map(t => (typeof t === 'string' ? t : t.text || '')).join('');
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Named ranges (workbook-level, filtered to a sheet if name provided)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function getNamedRanges(wb, sheetName) {
153
+ const results = [];
154
+ try {
155
+ const model = wb.definedNames?.model;
156
+ if (!Array.isArray(model)) return results;
157
+ for (const def of model) {
158
+ if (!def.ranges?.length) continue;
159
+ if (sheetName) {
160
+ const relevant = def.ranges.filter(r => r.includes(sheetName + '!'));
161
+ if (relevant.length) results.push({ name: def.name, ranges: relevant });
162
+ } else {
163
+ results.push({ name: def.name, ranges: def.ranges });
164
+ }
165
+ }
166
+ } catch (_) {}
167
+ return results;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Sheet dump
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function dumpSheet(ws, wb, opts = {}) {
175
+ const { maxRows = null, maxCols = null, compact = false } = opts;
176
+ const lines = [];
177
+
178
+ lines.push(`=== Sheet: ${ws.name} ===`);
179
+
180
+ // Frozen panes
181
+ const views = ws.views || [];
182
+ const frozen = views.find(v => v.state === 'frozen');
183
+ if (frozen) {
184
+ lines.push(`Frozen: row ${frozen.ySplit ?? 0}, col ${frozen.xSplit ?? 0}`);
185
+ }
186
+
187
+ // Column widths + hidden columns
188
+ const totalCols = maxCols ? Math.min(ws.columnCount, maxCols) : ws.columnCount;
189
+ const colWidths = [];
190
+ const hiddenCols = [];
191
+ for (let c = 1; c <= totalCols; c++) {
192
+ const col = ws.getColumn(c);
193
+ const letter = colLetter(c);
194
+ if (col.hidden) hiddenCols.push(letter);
195
+ if (col.width) colWidths.push(`${letter}(${Math.round(col.width)})`);
196
+ }
197
+ if (colWidths.length) lines.push(`Columns: ${colWidths.join(' ')}`);
198
+ if (hiddenCols.length) lines.push(`Hidden columns: ${hiddenCols.join(', ')}`);
199
+ if (maxCols && ws.columnCount > maxCols) {
200
+ lines.push(`(${ws.columnCount - maxCols} more columns truncated at --max-cols ${maxCols})`);
201
+ }
202
+
203
+ // Merged cells
204
+ const merges = Object.keys(ws._merges || {});
205
+ if (merges.length) lines.push(`Merged: ${merges.join(', ')}`);
206
+
207
+ // Auto-filter
208
+ if (ws.autoFilter) {
209
+ const af = typeof ws.autoFilter === 'string'
210
+ ? ws.autoFilter
211
+ : (ws.autoFilter.ref || JSON.stringify(ws.autoFilter));
212
+ lines.push(`Auto-filter: ${af}`);
213
+ }
214
+
215
+ // Print area
216
+ try {
217
+ if (ws.pageSetup?.printArea) {
218
+ lines.push(`Print area: ${ws.pageSetup.printArea}`);
219
+ }
220
+ } catch (_) {}
221
+
222
+ // Named ranges relevant to this sheet
223
+ const namedRanges = getNamedRanges(wb, ws.name);
224
+ if (namedRanges.length) {
225
+ lines.push(`Named ranges:`);
226
+ for (const nr of namedRanges) {
227
+ lines.push(` ${nr.name}: ${nr.ranges.join(', ')}`);
228
+ }
229
+ }
230
+
231
+ // Tables
232
+ try {
233
+ const tableMap = ws.tables;
234
+ if (tableMap && typeof tableMap === 'object') {
235
+ const tables = typeof tableMap.forEach === 'function'
236
+ ? (() => { const a = []; tableMap.forEach(t => a.push(t)); return a; })()
237
+ : Object.values(tableMap);
238
+ for (const t of tables) {
239
+ const model = t.table || t.model || t;
240
+ const name = model.name || model.displayName || '(unnamed)';
241
+ const ref = model.ref || model.tableRef || '';
242
+ const cols = (model.columns || []).map(c => c.name).filter(Boolean);
243
+ let desc = `Table: "${name}" ${ref}`;
244
+ if (cols.length) desc += ` — columns: ${cols.join(', ')}`;
245
+ lines.push(desc);
246
+ }
247
+ }
248
+ } catch (_) {}
249
+
250
+ // Images
251
+ try {
252
+ const images = typeof ws.getImages === 'function' ? ws.getImages() : [];
253
+ for (const img of images) {
254
+ if (img.range) {
255
+ const tl = img.range.tl;
256
+ const br = img.range.br;
257
+ if (tl && br) {
258
+ lines.push(`Image: ${colLetter(Math.floor(tl.col) + 1)}${Math.floor(tl.row) + 1} to ${colLetter(Math.floor(br.col) + 1)}${Math.floor(br.row) + 1}`);
259
+ } else if (tl) {
260
+ lines.push(`Image at: ${colLetter(Math.floor(tl.col) + 1)}${Math.floor(tl.row) + 1}`);
261
+ }
262
+ } else {
263
+ lines.push(`Image: (position unknown)`);
264
+ }
265
+ }
266
+ } catch (_) {}
267
+
268
+ lines.push('');
269
+
270
+ // Rows
271
+ const rowLimit = maxRows ? Math.min(ws.rowCount, maxRows) : ws.rowCount;
272
+
273
+ for (let r = 1; r <= rowLimit; r++) {
274
+ const row = ws.getRow(r);
275
+ const cells = [];
276
+ const isHidden = row.hidden;
277
+
278
+ for (let c = 1; c <= totalCols; c++) {
279
+ const cell = row.getCell(c);
280
+ const raw = cell.value;
281
+ if (raw == null || raw === '') continue;
282
+
283
+ const ref = `${colLetter(c)}${r}`;
284
+ const tags = [];
285
+
286
+ // Formula (handle both standalone and shared formulas)
287
+ if (cell.type === ExcelJS.ValueType.Formula) {
288
+ if (typeof raw === 'object') {
289
+ if (raw.formula) {
290
+ tags.push(`formula: =${raw.formula}`);
291
+ } else if (raw.sharedFormula) {
292
+ tags.push(`shared formula ref: ${raw.sharedFormula}`);
293
+ }
294
+ }
295
+ }
296
+
297
+ // Number format
298
+ if (cell.numFmt && cell.numFmt !== 'General') {
299
+ tags.push(`numFmt: ${cell.numFmt}`);
300
+ }
301
+
302
+ // Font
303
+ const fontTags = describeFont(cell.font, compact);
304
+ if (fontTags.length) tags.push(...fontTags);
305
+
306
+ // Fill
307
+ const fillDesc = describeFill(cell.fill, compact);
308
+ if (fillDesc) tags.push(fillDesc);
309
+
310
+ // Alignment
311
+ if (cell.alignment?.horizontal && cell.alignment.horizontal !== 'general') {
312
+ tags.push(`align:${cell.alignment.horizontal}`);
313
+ }
314
+
315
+ // Hyperlink
316
+ if (cell.hyperlink) {
317
+ tags.push(`link: ${cell.hyperlink}`);
318
+ } else if (typeof raw === 'object' && raw.hyperlink) {
319
+ tags.push(`link: ${raw.hyperlink}`);
320
+ }
321
+
322
+ // Comment / note
323
+ const noteText = describeNote(cell.note);
324
+ if (noteText) {
325
+ tags.push(`note: ${noteText.replace(/\n/g, ' ').trim()}`);
326
+ }
327
+
328
+ // Data validation
329
+ if (cell.dataValidation) {
330
+ const dv = cell.dataValidation;
331
+ if (dv.type === 'list' && dv.formulae?.length) {
332
+ tags.push(`validation: list [${dv.formulae[0]}]`);
333
+ } else if (dv.type) {
334
+ const parts = [dv.type];
335
+ if (dv.operator) parts.push(dv.operator);
336
+ if (dv.formulae?.length) parts.push(dv.formulae.join(', '));
337
+ tags.push(`validation: ${parts.join(' ')}`);
338
+ }
339
+ }
340
+
341
+ const displayVal = formatValue(raw);
342
+ const tagStr = tags.length ? ` [${tags.join('] [')}]` : '';
343
+ cells.push(` ${ref}: ${displayVal}${tagStr}`);
344
+ }
345
+
346
+ if (cells.length === 0) {
347
+ const hiddenTag = isHidden ? ' [hidden]' : '';
348
+ lines.push(`--- Row ${r} (empty)${hiddenTag} ---`);
349
+ } else {
350
+ const rowBold = row.font?.bold ? ' [bold]' : '';
351
+ const hiddenTag = isHidden ? ' [hidden]' : '';
352
+ lines.push(`--- Row ${r}${rowBold}${hiddenTag} ---`);
353
+ lines.push(...cells);
354
+ }
355
+ }
356
+
357
+ if (maxRows && ws.rowCount > maxRows) {
358
+ lines.push('');
359
+ lines.push(`... ${ws.rowCount - maxRows} more rows (truncated at --max-rows ${maxRows})`);
360
+ }
361
+
362
+ return lines.join('\n');
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // JSON dump (structured per-cell output)
367
+ // ---------------------------------------------------------------------------
368
+
369
+ function jsonValue(v) {
370
+ if (v == null) return null;
371
+ if (v instanceof Date) return v.toISOString();
372
+ if (typeof v === 'object') {
373
+ if (v.richText) return v.richText.map(r => r.text).join('');
374
+ if (v.hyperlink) return { text: v.text || v.hyperlink, hyperlink: v.hyperlink };
375
+ if (v.formula || v.sharedFormula) {
376
+ const out = {};
377
+ if (v.formula) out.formula = v.formula;
378
+ if (v.sharedFormula) out.sharedFormulaRef = v.sharedFormula;
379
+ const result = v.result;
380
+ if (result == null) {
381
+ out.result = null;
382
+ } else if (typeof result === 'object') {
383
+ if (result.error) out.result = `#${result.error}`;
384
+ else if (result.richText) out.result = result.richText.map(r => r.text).join('');
385
+ else out.result = result;
386
+ } else {
387
+ out.result = result;
388
+ }
389
+ return out;
390
+ }
391
+ if (v.error) return `#${v.error}`;
392
+ }
393
+ return v;
394
+ }
395
+
396
+ function dumpSheetJSON(ws, wb, opts = {}) {
397
+ const { maxRows = null, maxCols = null } = opts;
398
+ const totalCols = maxCols ? Math.min(ws.columnCount, maxCols) : ws.columnCount;
399
+ const rowLimit = maxRows ? Math.min(ws.rowCount, maxRows) : ws.rowCount;
400
+
401
+ const out = {
402
+ name: ws.name,
403
+ state: ws.state || 'visible',
404
+ rowCount: ws.rowCount,
405
+ columnCount: ws.columnCount,
406
+ truncated: {
407
+ rows: maxRows && ws.rowCount > maxRows ? ws.rowCount - maxRows : 0,
408
+ cols: maxCols && ws.columnCount > maxCols ? ws.columnCount - maxCols : 0,
409
+ },
410
+ frozen: null,
411
+ columns: [],
412
+ hiddenColumns: [],
413
+ merges: Object.keys(ws._merges || {}),
414
+ autoFilter: null,
415
+ printArea: null,
416
+ namedRanges: getNamedRanges(wb, ws.name),
417
+ tables: [],
418
+ images: [],
419
+ cells: [],
420
+ };
421
+
422
+ // Frozen panes
423
+ const frozen = (ws.views || []).find(v => v.state === 'frozen');
424
+ if (frozen) out.frozen = { rowSplit: frozen.ySplit ?? 0, colSplit: frozen.xSplit ?? 0 };
425
+
426
+ // Columns
427
+ for (let c = 1; c <= totalCols; c++) {
428
+ const col = ws.getColumn(c);
429
+ const letter = colLetter(c);
430
+ if (col.hidden) out.hiddenColumns.push(letter);
431
+ out.columns.push({ letter, width: col.width || null, hidden: !!col.hidden });
432
+ }
433
+
434
+ // Auto-filter
435
+ if (ws.autoFilter) {
436
+ out.autoFilter = typeof ws.autoFilter === 'string'
437
+ ? ws.autoFilter
438
+ : (ws.autoFilter.ref || null);
439
+ }
440
+
441
+ // Print area
442
+ try { if (ws.pageSetup?.printArea) out.printArea = ws.pageSetup.printArea; } catch (_) {}
443
+
444
+ // Tables
445
+ try {
446
+ const tableMap = ws.tables;
447
+ if (tableMap && typeof tableMap === 'object') {
448
+ const tables = typeof tableMap.forEach === 'function'
449
+ ? (() => { const a = []; tableMap.forEach(t => a.push(t)); return a; })()
450
+ : Object.values(tableMap);
451
+ for (const t of tables) {
452
+ const model = t.table || t.model || t;
453
+ out.tables.push({
454
+ name: model.name || model.displayName || null,
455
+ ref: model.ref || model.tableRef || null,
456
+ columns: (model.columns || []).map(c => c.name).filter(Boolean),
457
+ });
458
+ }
459
+ }
460
+ } catch (_) {}
461
+
462
+ // Images
463
+ try {
464
+ const images = typeof ws.getImages === 'function' ? ws.getImages() : [];
465
+ for (const img of images) {
466
+ if (img.range) {
467
+ const tl = img.range.tl, br = img.range.br;
468
+ out.images.push({
469
+ tl: tl ? `${colLetter(Math.floor(tl.col) + 1)}${Math.floor(tl.row) + 1}` : null,
470
+ br: br ? `${colLetter(Math.floor(br.col) + 1)}${Math.floor(br.row) + 1}` : null,
471
+ });
472
+ }
473
+ }
474
+ } catch (_) {}
475
+
476
+ // Cells
477
+ for (let r = 1; r <= rowLimit; r++) {
478
+ const row = ws.getRow(r);
479
+ for (let c = 1; c <= totalCols; c++) {
480
+ const cell = row.getCell(c);
481
+ const raw = cell.value;
482
+ if (raw == null || raw === '') continue;
483
+
484
+ const entry = {
485
+ ref: `${colLetter(c)}${r}`,
486
+ row: r,
487
+ col: c,
488
+ value: jsonValue(raw),
489
+ };
490
+ if (cell.numFmt && cell.numFmt !== 'General') entry.numFmt = cell.numFmt;
491
+ if (cell.font?.bold) entry.bold = true;
492
+ if (cell.font?.italic) entry.italic = true;
493
+ if (cell.font?.color?.argb) entry.color = cell.font.color.argb;
494
+ if (cell.fill?.type === 'pattern' && cell.fill.fgColor?.argb) entry.fill = cell.fill.fgColor.argb;
495
+ if (cell.alignment?.horizontal && cell.alignment.horizontal !== 'general') entry.align = cell.alignment.horizontal;
496
+ if (cell.hyperlink) entry.hyperlink = cell.hyperlink;
497
+ if (cell.note) entry.note = describeNote(cell.note);
498
+ if (cell.dataValidation) entry.dataValidation = cell.dataValidation;
499
+ if (row.hidden) entry.rowHidden = true;
500
+ out.cells.push(entry);
501
+ }
502
+ }
503
+
504
+ return out;
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // List sheets mode
509
+ // ---------------------------------------------------------------------------
510
+
511
+ function listSheets(wb) {
512
+ const lines = [];
513
+ for (const ws of wb.worksheets) {
514
+ const vis = ws.state === 'hidden' ? ' [hidden]'
515
+ : ws.state === 'veryHidden' ? ' [very hidden]'
516
+ : '';
517
+ lines.push(`${ws.name} ${ws.rowCount} rows × ${ws.columnCount} cols${vis}`);
518
+ }
519
+ return lines.join('\n');
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Main
524
+ // ---------------------------------------------------------------------------
525
+
526
+ async function main() {
527
+ const opts = parseArgs(process.argv.slice(2));
528
+
529
+ if (opts.help) { printHelp(); process.exit(0); }
530
+ if (opts.positional.length < 1) { printHelp(); process.exit(1); }
531
+
532
+ const xlsxPath = path.resolve(opts.positional[0]);
533
+ const sheetFilter = opts.positional[1] || null;
534
+
535
+ if (!fs.existsSync(xlsxPath)) {
536
+ console.error(`File not found: ${xlsxPath}`);
537
+ process.exit(1);
538
+ }
539
+
540
+ const wb = new ExcelJS.Workbook();
541
+ await wb.xlsx.readFile(xlsxPath);
542
+
543
+ // --list-sheets: print summary and exit
544
+ if (opts.listSheets) {
545
+ console.log(listSheets(wb));
546
+ process.exit(0);
547
+ }
548
+
549
+ const sheets = sheetFilter
550
+ ? [wb.getWorksheet(sheetFilter)].filter(Boolean)
551
+ : wb.worksheets;
552
+
553
+ if (sheets.length === 0) {
554
+ console.error(sheetFilter
555
+ ? `Sheet "${sheetFilter}" not found. Available: ${wb.worksheets.map(s => s.name).join(', ')}`
556
+ : 'No sheets in workbook');
557
+ process.exit(1);
558
+ }
559
+
560
+ const baseName = path.basename(xlsxPath, path.extname(xlsxPath));
561
+
562
+ const dumpOpts = { maxRows: opts.maxRows, maxCols: opts.maxCols, compact: opts.compact };
563
+
564
+ // --json mode: structured per-cell output
565
+ if (opts.json) {
566
+ const payload = sheets.map(ws => dumpSheetJSON(ws, wb, dumpOpts));
567
+ const json = JSON.stringify(sheets.length === 1 ? payload[0] : payload, null, 2);
568
+
569
+ if (opts.stdout) {
570
+ process.stdout.write(json + '\n');
571
+ process.exit(0);
572
+ }
573
+ const outDir = path.join(process.cwd(), '.xlsx-read');
574
+ fs.mkdirSync(outDir, { recursive: true });
575
+ const outFile = path.join(outDir, `${baseName}.json`);
576
+ fs.writeFileSync(outFile, json, 'utf8');
577
+ console.log(outFile);
578
+ process.exit(0);
579
+ }
580
+
581
+ // --stdout: print text dump to console
582
+ if (opts.stdout) {
583
+ for (const ws of sheets) {
584
+ console.log(dumpSheet(ws, wb, dumpOpts));
585
+ console.log('');
586
+ }
587
+ process.exit(0);
588
+ }
589
+
590
+ // Default: write text dump to .xlsx-read/ files
591
+ const outDir = path.join(process.cwd(), '.xlsx-read');
592
+ fs.mkdirSync(outDir, { recursive: true });
593
+
594
+ for (const ws of sheets) {
595
+ const content = dumpSheet(ws, wb, dumpOpts);
596
+ const safeName = ws.name.replace(/[^a-zA-Z0-9_-]/g, '_');
597
+ const outFile = path.join(outDir, `${baseName}--${safeName}.txt`);
598
+ fs.writeFileSync(outFile, content, 'utf8');
599
+ console.log(outFile);
600
+ }
601
+ }
602
+
603
+ main().catch((err) => {
604
+ console.error(err.message);
605
+ process.exit(1);
606
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "xlsx-for-ai",
3
+ "version": "1.1.0",
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
+ "main": "index.js",
6
+ "bin": {
7
+ "xlsx-for-ai": "index.js",
8
+ "cursor-reads-xlsx": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "cursor-rule-template",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "xlsx",
18
+ "excel",
19
+ "ai",
20
+ "claude",
21
+ "cursor",
22
+ "copilot",
23
+ "chatgpt",
24
+ "llm",
25
+ "agent",
26
+ "cli",
27
+ "spreadsheet",
28
+ "text",
29
+ "json",
30
+ "converter",
31
+ "ai-readable"
32
+ ],
33
+ "author": "senoff",
34
+ "license": "MIT",
35
+ "homepage": "https://github.com/senoff/xlsx-for-ai",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/senoff/xlsx-for-ai.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/senoff/xlsx-for-ai/issues"
42
+ },
43
+ "dependencies": {
44
+ "exceljs": "^4.4.0"
45
+ }
46
+ }