xlsx-toolkit
Single Rust binary with five subcommands, built on umya-spreadsheet (read+write) and clap. The skill directory is a cargo project — source in src/, binary at bin/xlsx-toolkit.
First-time setup
Invoke via the shim, which picks a prebuilt for the current platform or builds from source on first use:
~/.claude/skills/xlsx-toolkit/bin/xlsx-toolkit --help
The skill ships a prebuilt only for aarch64-apple-darwin. On any other platform the shim runs cargo build --release once and caches the result in target/release/. Subsequent runs go straight to the cached binary — no cargo overhead.
Optional alias if you'll use this often:
alias xlsx-toolkit='~/.claude/skills/xlsx-toolkit/bin/xlsx-toolkit'
macOS Gatekeeper: if the bundled binary arrived with a com.apple.quarantine xattr (downloaded .skill), the shim strips it automatically. If macOS still blocks it ("unidentified developer"), run xattr -dr com.apple.quarantine ~/.claude/skills/xlsx-toolkit/bin and retry.
The rest of this file assumes you can invoke the binary as xlsx-toolkit. If the alias isn't set, use the full path.
When to use what
| Task | Subcommand |
|---|---|
| "What's in this xlsx?" / unknown workbook | inspect |
| Dump a sheet (or all sheets) to CSV/TSV | to-csv |
| Build a new xlsx from a JSON spec | write |
| Compare two workbooks | diff |
| Per-column fill % / data quality | coverage |
For anything else, read references/patterns.md — it covers formulas vs cached values, dates, merged cells, multi-row headers, hidden sheets, write-side details, and how to drop down to the umya-spreadsheet API when the subcommands don't fit.
Workflow
- Always inspect first. Run
xlsx-toolkit inspect <file>before doing anything else. It prints sheet names, dimensions, headers, formula count, merged ranges, and a sample. Without this, you'll guess wrong about what's in the workbook. - Convert to CSV when piping to other tools.
to-csv --sheet NAMEto stdout is the right move when feeding data tojq,grep,awk,csvkit, etc. Don't try to parse xlsx with shell. - For new workbooks, prefer
writeover hand-rolling. It handles freeze panes, autofilter, column widths, and formulas through a JSON spec. If the layout is unusual, fall back to writing a small cargo binary against the patterns inreferences/patterns.md. - Coverage is the data-quality lens. Use
coverageto find columns that are mostly empty (often imported-but-unused fields) or to confirm a migration populated what it should. The HubSpot data-model coverage doc in this monorepo is exactly this shape.
Quick examples
Inspect
xlsx-toolkit inspect ./data.xlsx --sample 5
xlsx-toolkit inspect ./data.xlsx --json | jq '.details[].headers'
xlsx-toolkit inspect ./data.xlsx --sheet Contacts
Convert
# One sheet → stdout (pipe to anything)
xlsx-toolkit to-csv ./data.xlsx --sheet Contacts | head
# All sheets → sibling .csv files next to the source
xlsx-toolkit to-csv ./data.xlsx
# TSV
xlsx-toolkit to-csv ./data.xlsx --sheet Contacts --delimiter $'\t'
Write
cat > /tmp/spec.json <<'EOF'
{
"sheets": [{
"name": "Report",
"headers": ["Item", "Qty", "Price", "Total"],
"rows": [
["apples", 3, 1.5, { "v": 0, "f": "B2*C2" }],
["bread", 2, 4.25, { "v": 0, "f": "B3*C3" }]
],
"freeze_header": true,
"autofilter": true,
"column_widths": [12, 6, 8, 10]
}]
}
EOF
xlsx-toolkit write --spec /tmp/spec.json --out report.xlsx
# Or pipe the spec on stdin:
cat /tmp/spec.json | xlsx-toolkit write --out report.xlsx
Cell values: plain JSON string | number | boolean | null for static data, or { "v": <value>, "f": "FORMULA", "t": "n|s|b" } for formulas / explicit types. Set the value AND the formula together — the cached v is what other tools see until Excel re-evaluates.
Diff
# Positional row diff (row N vs row N) across shared sheets
xlsx-toolkit diff before.xlsx after.xlsx
# Key-based: match rows by a header column value (stable across reorderings)
xlsx-toolkit diff before.xlsx after.xlsx --key "Property"
# Restrict to one sheet, JSON output
xlsx-toolkit diff before.xlsx after.xlsx --sheet Contacts --json
Coverage
# Per-column fill % on every sheet
xlsx-toolkit coverage ./data.xlsx
# Only columns with ≥50% fill
xlsx-toolkit coverage ./data.xlsx --threshold 0.5 --sheet Contacts
Output conventions
- Human-readable goes to stdout; pass
--json(where supported) for machine output. - Status lines from
writeandto-csv(file-writes) go to stderr, so stdout stays clean for piping. - All subcommands exit non-zero on usage errors and propagate
anyhowerrors with context on failure.
When NOT to use this
- Inside a Rust crate that already depends on
umya-spreadsheetorcalamine. Use the in-tree dep directly — don't shell out to global binaries for production logic. - For
.xls(old binary format) — umya is xlsx-only. Convert first (libreoffice --headless --convert-to xlsx file.xls) or read it elsewhere. - For
.xlsmmacros — umya reads the data but doesn't preserve macros on write. Don't round-trip a macro-enabled workbook throughwrite. - For 100k+ row workbooks — umya is fine but loads everything into memory. If memory is tight, dump to CSV and stream from there, or write a one-off using
calamine(much faster reader, read-only).
Project layout
~/.claude/skills/xlsx-toolkit/
├── SKILL.md
├── Cargo.toml
├── .gitignore ← /target/ and *.skill ignored
├── references/
│ └── patterns.md ← gotchas, advanced usage, umya API notes
├── src/
│ ├── main.rs ← clap entry point
│ └── commands/
│ ├── mod.rs
│ ├── common.rs ← shared read/walk helpers
│ ├── inspect.rs
│ ├── to_csv.rs
│ ├── write.rs
│ ├── diff.rs
│ └── coverage.rs
├── bin/
│ ├── xlsx-toolkit ← shell shim, dispatches to prebuilt or cargo build
│ └── aarch64-apple-darwin/
│ └── xlsx-toolkit ← shipped prebuilt (~4 MB)
└── target/release/xlsx-toolkit ← cargo-built fallback (gitignored)
To add another platform: rustup target add <triple>, cargo build --release --target <triple>, then copy target/<triple>/release/xlsx-toolkit to bin/<triple>/xlsx-toolkit. The shim picks it up automatically. For Linux targets from a Mac, use cross (cross build --release --target x86_64-unknown-linux-gnu) to get the right glibc.