xl-cli-tools

CLI tools for viewing and editing Excel files
Log | Files | Refs | README | LICENSE

commit d39ef250f7c31ff1f4df2dd976bb5a4f78a41ae1
parent 01c053c325aa199b67f50d9dbae23a96a43e3196
Author: Erik Loualiche <[email protected]>
Date:   Wed, 18 Mar 2026 08:36:29 -0500

docs: add xldiff demo, update README with 2x2 GIF grid and all four tools

- Add demo/old.xlsx + demo/new.xlsx diff pair and xldiff.tape
- Record demo/xldiff.gif showing key-based diff, markdown, and JSON output
- Rewrite README: 2x2 GIF grid, nav bar, quick usage examples before install
- Add xldiff section with examples, sample output, diff modes
- Trim polars features (default-features = false)
- Update create_demo.rs to generate diff pair files

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

Diffstat:
MCargo.lock | 56--------------------------------------------------------
MCargo.toml | 2+-
MREADME.md | 193++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Ademo/new.xlsx | 0
Ademo/old.xlsx | 0
Ademo/xldiff.gif | 0
Ademo/xldiff.tape | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mexamples/create_demo.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 229 insertions(+), 118 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -460,17 +460,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "comfy-table" -version = "7.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width", -] - -[[package]] name = "compact_str" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -553,29 +542,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -637,15 +603,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1105,12 +1062,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1450,7 +1401,6 @@ dependencies = [ "bytemuck", "chrono", "chrono-tz", - "comfy-table", "either", "hashbrown 0.14.5", "hashbrown 0.15.5", @@ -2517,12 +2467,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -31,7 +31,7 @@ path = "src/bin/xldiff.rs" [dependencies] calamine = "0.26" -polars = { version = "0.46", features = ["dtype-date", "dtype-datetime", "dtype-duration", "csv"] } +polars = { version = "0.46", default-features = false, features = ["dtype-datetime", "csv"] } clap = { version = "4", features = ["derive"] } anyhow = "1" umya-spreadsheet = "2" diff --git a/README.md b/README.md @@ -1,21 +1,27 @@ <div align="center"> <h1>xl-cli-tools</h1> -<h3>View and edit Excel files from the command line</h3> +<h3>View, edit, query, and diff Excel files from the command line</h3> [![Vibecoded](https://img.shields.io/badge/vibecoded-%E2%9C%A8-blueviolet)](https://claude.ai) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) <table> <tr> -<td align="center" width="33%"><strong>xlcat</strong> — view</td> -<td align="center" width="33%"><strong>xlset</strong> — edit</td> -<td align="center" width="33%"><strong>xlfilter</strong> — query</td> +<td align="center" width="50%"><strong>xlcat</strong> — view</td> +<td align="center" width="50%"><strong>xlset</strong> — edit</td> </tr> <tr> <td><img src="demo/xlcat.gif" alt="xlcat demo" /></td> <td><img src="demo/xlset.gif" alt="xlset demo" /></td> +</tr> +<tr> +<td align="center" width="50%"><strong>xlfilter</strong> — query</td> +<td align="center" width="50%"><strong>xldiff</strong> — compare</td> +</tr> +<tr> <td><img src="demo/xlfilter.gif" alt="xlfilter demo" /></td> +<td><img src="demo/xldiff.gif" alt="xldiff demo" /></td> </tr> </table> @@ -23,11 +29,25 @@ *** -Three binaries, no runtime dependencies: +[**xlcat**](#xlcat--view-excel-files) · [**xlset**](#xlset--edit-excel-cells) · [**xlfilter**](#xlfilter--query-and-filter) · [**xldiff**](#xldiff--compare-two-sheets) · [**Install**](#installation) · [**Claude Code**](#claude-code-integration) + +*** + +Four binaries, no runtime dependencies: -- **`xlcat`** — view xlsx/xls files as markdown tables or CSV -- **`xlset`** — modify cells in existing xlsx files, preserving formatting -- **`xlfilter`** — filter, sort, and query rows from spreadsheets +```bash +# View a spreadsheet +xlcat report.xlsx + +# Edit a cell +xlset report.xlsx B3=42 + +# Filter rows +xlfilter data.xlsx --where "Amount>1000" --sort "Amount:desc" + +# Diff two files +xldiff old.xlsx new.xlsx --key ID +``` ## Installation @@ -37,16 +57,13 @@ Download from [Releases](https://github.com/LouLouLibs/xl-cli-tools/releases): ```bash # Apple Silicon -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlcat-aarch64-apple-darwin -o ~/.local/bin/xlcat -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlset-aarch64-apple-darwin -o ~/.local/bin/xlset -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlfilter-aarch64-apple-darwin -o ~/.local/bin/xlfilter -chmod +x ~/.local/bin/xlcat ~/.local/bin/xlset ~/.local/bin/xlfilter - -# Intel Mac -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlcat-x86_64-apple-darwin -o ~/.local/bin/xlcat -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlset-x86_64-apple-darwin -o ~/.local/bin/xlset -curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlfilter-x86_64-apple-darwin -o ~/.local/bin/xlfilter -chmod +x ~/.local/bin/xlcat ~/.local/bin/xlset ~/.local/bin/xlfilter +for tool in xlcat xlset xlfilter xldiff; do + curl -L "https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/${tool}-aarch64-apple-darwin" \ + -o ~/.local/bin/$tool +done +chmod +x ~/.local/bin/xl{cat,set,filter,diff} + +# Intel Mac — replace aarch64 with x86_64 ``` ### From source @@ -72,13 +89,9 @@ xlcat report.xlsx --describe # Pick a sheet in a multi-sheet workbook xlcat report.xlsx --sheet Revenue -# First 10 rows +# First 10 rows / last 5 rows / both xlcat report.xlsx --head 10 - -# Last 5 rows xlcat report.xlsx --tail 5 - -# Both xlcat report.xlsx --head 10 --tail 5 # All rows (overrides large-file gate) @@ -120,6 +133,56 @@ xlcat report.xlsx --csv --head 100 > subset.csv - **Multiple sheets:** lists schemas, pick one with `--sheet` - **Large file (>1MB):** schema + first 25 rows (override with `--max-size 5M`) +## xlset — Edit Excel Cells + +```bash +# Set a single cell +xlset report.xlsx A2=42 + +# Set multiple cells +xlset report.xlsx A2=42 B2="hello world" C2=true + +# Preserve leading zeros with type tag +xlset report.xlsx A2:str=07401 + +# Target a specific sheet +xlset report.xlsx --sheet Revenue A2=42 + +# Write to a new file (don't modify original) +xlset report.xlsx --output modified.xlsx A2=42 + +# Bulk update from CSV +xlset report.xlsx --from updates.csv + +# Bulk from stdin +echo "A1,42" | xlset report.xlsx --from - +``` + +### Type inference + +Values are auto-detected: `42` becomes a number, `true` becomes boolean, `2024-01-15` becomes a date. Override with tags when needed: + +| Tag | Effect | +|-----|--------| +| `:str` | Force string (`A1:str=07401` preserves leading zero) | +| `:num` | Force number | +| `:bool` | Force boolean | +| `:date` | Force date | + +### CSV format for `--from` + +```csv +cell,value +A1,42 +B2,hello world +C3:str,07401 +D4,"value with, comma" +``` + +### What gets preserved + +xlset modifies only the cells you specify. Everything else is untouched: formatting, formulas, charts, conditional formatting, data validation, merged cells, images. + ## xlfilter — Query and Filter ```bash @@ -145,9 +208,6 @@ xlfilter data.xlsx --sort "Amount:desc" --limit 10 # Contains filter (case-insensitive) xlfilter data.xlsx --where "Name~john" -# Head/tail (applied before filtering) -xlfilter data.xlsx --head 100 --where "Status=Active" - # Skip metadata rows above the real header xlfilter data.xlsx --skip 2 @@ -173,66 +233,77 @@ xlfilter data.xlsx --sheet Revenue --where "Amount>5000" Numeric columns compare numerically; string columns compare lexicographically. Row count is printed to stderr. -## xlset — Edit Excel Cells +## xldiff — Compare Two Sheets ```bash -# Set a single cell -xlset report.xlsx A2=42 +# Positional diff (whole-row comparison) +xldiff old.xlsx new.xlsx -# Set multiple cells -xlset report.xlsx A2=42 B2="hello world" C2=true +# Key-based diff (match rows by ID, compare cell by cell) +xldiff old.xlsx new.xlsx --key ID -# Preserve leading zeros with type tag -xlset report.xlsx A2:str=07401 +# Composite key +xldiff old.xlsx new.xlsx --key Date,Ticker -# Target a specific sheet -xlset report.xlsx --sheet Revenue A2=42 +# Compare sheets within the same file +xldiff report.xlsx:Q1 report.xlsx:Q2 -# Write to a new file (don't modify original) -xlset report.xlsx --output modified.xlsx A2=42 +# Float tolerance (differences <= 0.01 treated as equal) +xldiff old.xlsx new.xlsx --key ID --tolerance 0.01 -# Bulk update from CSV -xlset report.xlsx --from updates.csv +# Only compare specific columns +xldiff old.xlsx new.xlsx --key ID --cols Name,Salary -# Bulk from stdin -echo "A1,42" | xlset report.xlsx --from - +# Skip metadata rows (different skip per file) +xldiff file1.xlsx file2.xlsx --skip 3,5 + +# Output formats +xldiff old.xlsx new.xlsx --key ID --format markdown +xldiff old.xlsx new.xlsx --key ID --format json +xldiff old.xlsx new.xlsx --key ID --format csv ``` -### Type inference +### Example output -Values are auto-detected: `42` becomes a number, `true` becomes boolean, `2024-01-15` becomes a date. Override with tags when needed: +``` +--- Sheet1 (old.xlsx) ++++ Sheet1 (new.xlsx) + +Added: 1 | Removed: 1 | Modified: 2 + +- ID: "3" Name: "Charlie" Department: "Engineering" Salary: "88000" ++ ID: "5" Name: "Eve" Department: "Marketing" Salary: "70000" +~ ID: "1" + Salary: "95000" → "98000" +~ ID: "2" + Department: "Marketing" → "Design" + Salary: "72000" → "75000" +``` -| Tag | Effect | -|-----|--------| -| `:str` | Force string (`A1:str=07401` preserves leading zero) | -| `:num` | Force number | -| `:bool` | Force boolean | -| `:date` | Force date | +### Diff modes -### CSV format for `--from` +**Positional (no `--key`):** Every column defines row identity. Reports added/removed rows only. -```csv -cell,value -A1,42 -B2,hello world -C3:str,07401 -D4,"value with, comma" -``` +**Key-based (`--key`):** Match rows by key columns, compare remaining columns cell by cell. Reports added, removed, and modified rows with per-cell changes. Supports composite keys, duplicate key detection, and float tolerance. -### What gets preserved +### Exit codes (diff convention) -xlset modifies only the cells you specify. Everything else is untouched: formatting, formulas, charts, conditional formatting, data validation, merged cells, images. +| Code | Meaning | +|------|---------| +| 0 | No differences | +| 1 | Differences found | +| 2 | Error | ## Claude Code integration -Claude Code skills (`/xlcat` and `/xlset`) are available in [claude-skills](https://github.com/LouLouLibs/claude-skills). Claude can view spreadsheets, analyze data, and make targeted edits in conversations. +Claude Code skills are available in [claude-skills](https://github.com/LouLouLibs/claude-skills). Claude can view spreadsheets, analyze data, filter rows, compare files, and make targeted edits in conversations. ## Exit codes | Code | Meaning | |------|---------| | 0 | Success | -| 1 | Runtime error | +| 1 | Runtime error (xldiff: differences found) | | 2 | Invalid arguments | ## License diff --git a/demo/new.xlsx b/demo/new.xlsx Binary files differ. diff --git a/demo/old.xlsx b/demo/old.xlsx Binary files differ. diff --git a/demo/xldiff.gif b/demo/xldiff.gif Binary files differ. diff --git a/demo/xldiff.tape b/demo/xldiff.tape @@ -0,0 +1,49 @@ +# VHS tape for recording xldiff demo GIF. +# Usage: vhs demo/xldiff.tape +# +# Prerequisites: +# - xldiff binary built and in PATH +# - Demo xlsx files created: cargo run --example create_demo + +Output demo/xldiff.gif + +Set FontSize 14 +Set Width 1100 +Set Height 550 +Set Padding 15 +Set Theme "GruvboxDarkHard" + +Set TypingSpeed 80ms + +Set Shell "bash" + +Hide + Type 'export PS1="> "' + Enter + Type "clear" + Enter +Show + +# ─── 1. SHOW THE INPUT FILES ─── +Type "xlcat demo/old.xlsx" +Enter +Sleep 3s + +Type "xlcat demo/new.xlsx" +Enter +Sleep 3s + +# ─── 2. KEY-BASED DIFF ─── +Type "xldiff demo/old.xlsx demo/new.xlsx --key ID" +Enter +Sleep 4s + +# ─── 3. MARKDOWN OUTPUT ─── +Type "xldiff demo/old.xlsx demo/new.xlsx --key ID --format markdown" +Enter +Sleep 4s + +# ─── 4. JSON OUTPUT ─── +Type "xldiff demo/old.xlsx demo/new.xlsx --key ID --format json" +Enter +Sleep 4s diff --git a/examples/create_demo.rs b/examples/create_demo.rs @@ -95,4 +95,51 @@ fn main() { wb.save(dir.join("budget.xlsx")).unwrap(); println!("Created demo/budget.xlsx"); } + + // --- old.xlsx & new.xlsx: diff demo pair --- + { + let mut wb = Workbook::new(); + let ws = wb.add_worksheet(); + ws.set_name("Sheet1").unwrap(); + ws.write_string(0, 0, "ID").unwrap(); + ws.write_string(0, 1, "Name").unwrap(); + ws.write_string(0, 2, "Department").unwrap(); + ws.write_string(0, 3, "Salary").unwrap(); + for (i, (id, name, dept, salary)) in [ + (1, "Alice", "Engineering", 95000.0), + (2, "Bob", "Marketing", 72000.0), + (3, "Charlie", "Engineering", 88000.0), + (4, "Dana", "Sales", 65000.0), + ].iter().enumerate() { + let row = (i + 1) as u32; + ws.write_number(row, 0, *id as f64).unwrap(); + ws.write_string(row, 1, *name).unwrap(); + ws.write_string(row, 2, *dept).unwrap(); + ws.write_number(row, 3, *salary).unwrap(); + } + wb.save(dir.join("old.xlsx")).unwrap(); + println!("Created demo/old.xlsx"); + + let mut wb = Workbook::new(); + let ws = wb.add_worksheet(); + ws.set_name("Sheet1").unwrap(); + ws.write_string(0, 0, "ID").unwrap(); + ws.write_string(0, 1, "Name").unwrap(); + ws.write_string(0, 2, "Department").unwrap(); + ws.write_string(0, 3, "Salary").unwrap(); + for (i, (id, name, dept, salary)) in [ + (1, "Alice", "Engineering", 98000.0), + (2, "Bob", "Design", 75000.0), + (4, "Dana", "Sales", 65000.0), + (5, "Eve", "Marketing", 70000.0), + ].iter().enumerate() { + let row = (i + 1) as u32; + ws.write_number(row, 0, *id as f64).unwrap(); + ws.write_string(row, 1, *name).unwrap(); + ws.write_string(row, 2, *dept).unwrap(); + ws.write_number(row, 3, *salary).unwrap(); + } + wb.save(dir.join("new.xlsx")).unwrap(); + println!("Created demo/new.xlsx"); + } }