The xl CLI provides a command-line interface for Excel operations, designed for LLM agents and automation.
Design Philosophy:
- Stateless — Each command is self-contained
- Explicit cell refs — Always use
A1,B5:D10notation - Global flags —
-ffor file,-sfor sheet,-ofor output - LLM-optimized output — Markdown tables, token-efficient
git clone https://github.com/TJC-LP/xl.git
cd xl
make installThis builds a native binary (no JDK required, instant startup) and installs xl to ~/.local/bin/. Ensure it's in your PATH:
export PATH="$HOME/.local/bin:$PATH"JAR install (requires JDK 17+): make install-jar
Uninstall: make uninstall
Update: After git pull, run make install (or make install-jar) again.
# Global flags (used with all commands)
-f, --file <path> # Input file (required for most commands)
-s, --sheet <name> # Sheet to operate on (required for unqualified ranges)
-o, --output <path> # Output file for mutations
-i, --in-place # Edit file in place (same as -o matching -f)
--stream # O(1) memory streaming for large files (search/stats/bounds/view + writes)
--max-size <MB> # Max uncompressed size for in-memory load (default 100, 0 = unlimited)
--backend <name> # XML backend: scalaxml (default) or saxstax (faster)
--no-recalc # Write verbs: apply the edit, recalculate nothing (alias --preserve-caches)
--preserve-caches # Same flag, spelled for the intent
--strict # Write verbs: exit 1 when the write's recalculation reports problems
# Read-only operations
xl -f model.xlsx sheets # List all sheets
xl -f model.xlsx names # List defined names (named ranges)
xl -f model.xlsx -s "P&L" bounds # Show used range
xl -f model.xlsx -s "P&L" view A1:D20 # View range as markdown
xl -f model.xlsx cell B5 # Get single cell details (sheet auto-detected if unambiguous)
xl -f model.xlsx search "Revenue" # Find cells by content (all sheets)
xl -f model.xlsx -s "P&L" stats B1:B100 # Numeric statistics for a range
xl -f model.xlsx -s "P&L" eval "=SUM(B1:B10)" # Evaluate formula (what-if)
xl -f model.xlsx -s "P&L" evala "=TRANSPOSE(A1:C2)" # Evaluate array formula (spill grid)
# Mutations (require -o or -i)
xl -f model.xlsx -s S1 -o output.xlsx put B5 1000000 # Write value
xl -f model.xlsx -s S1 -o output.xlsx putf C5 "=B5*1.1" # Write formula
# What-if analysis with overrides
xl -f model.xlsx -s S1 eval "=B1*1.1" --with "B1=100" # Evaluate with temporary values
# No file needed
xl new report.xlsx --sheet Data --sheet Summary # Create a blank workbook
xl functions # List all 108 supported functions
xl rasterizers # List available PNG/PDF backendsGlobal flags must come before the verb:
xl -f x.xlsx --strict recalc, neverxl -f x.xlsx recalc --strict(decline reportsUnexpected argument: recalc).
Every write verb that changes cell content ends with a recalculation scoped to the edit's dirty
dependency cone — the changed cells plus their transitive dependents (cross-sheet included) plus
the always-dirty INDIRECT/OFFSET cells. Cached values outside that cone are never rewritten, so
a book whose caches come from another engine keeps them.
--no-recalc (alias --preserve-caches) drops the recalculation entirely: the edit lands and no
cached value is recomputed. Use it when an external calculator owns the numbers. Honored by put,
putf, fill, copy, batch, insert-rows, insert-cols, delete-rows, delete-cols;
recalc rejects it as a contradiction.
How completely caches survive depends on the verb:
-
Non-structural (
put,putf,fill,copy,batch): every existing cached value survives byte-identical, cone included. The summary says so. -
Structural (
insert-rows,insert-cols,delete-rows,delete-cols): a structural edit moves cells, rewrites formula text and rewrites defined names, so xl invalidates the cache of every formula the edit could have changed and writes those cells without a<v>. It never re-asserts a pre-edit cache: whether a formula the edit did reach still has its old answer cannot be decided without recalculating, which is precisely what the flag refuses. (A formula reached through a shrunk defined name, or a static dependent of anINDIRECTcell, keeps its text byte-identical while its answer changes underneath it.)A cache survives only when the edit provably could not have changed it: the formula's text is byte-identical after the rewrite, it did not relocate, and nothing it transitively reads was moved or removed. So an edit below or beside your data preserves everything, while an edit inside a block preserves the rows above the cut and drops the rest. The summary counts both halves:
Recalculation skipped (--no-recalc): 4 cached value(s) preserved, 7 formula(s) invalidated by the edit left uncached (recalculate externally)One consequence worth knowing: volatile formulas (
TODAY(),NOW(),RAND()) above the cut keep their cached values too. The flag means "do not recalculate", and a volatile is no exception — Excel refreshes them on open regardless.Reopening the file in Excel, or a later
xl recalc, fills those back in. A missing<v>is a visible gap that any recalculation repairs; a wrong one is silent and permanent, which is why xl never re-asserts a cache the edit invalidated. What it does not claim: a cache rides through only when the pre-edit dependency graph shows no path from it to the edited sheet, and a reference that graph cannot resolve — a multi-area or intersection defined name, a structured reference, an external link — can hide such a path. xl withdraws the caches of formulas that name a defined name it cannot parse, precisely because the graph is blind there; the remaining cases are tracked in #507 and affect a normal recalculating write too. If you need every formula cached after a structural edit, do not pass--no-recalc.
xl -f external-model.xlsx -s Data -o out.xlsx --no-recalc put B5 1000
xl -f external-model.xlsx -s Data -o out.xlsx --preserve-caches batch ops.jsonBy default a write reports formula-evaluation errors, iterative non-convergence and data-table seed
warnings in its summary and still exits 0. --strict promotes those to exit 1 while printing
the same summary — for CI and scripted pipelines. Excel error values (#DIV/0!, #N/A) are data
conditions, not failures, and never gate.
xl -f model.xlsx -o out.xlsx --strict recalc # exit 1 if any formula failed to evaluateWith -o the output file is written even on a strict failure (the gate only sets the exit code).
With -i the temp file is discarded and the input is left byte-identical; the summary then says
NOT saved (--strict failure): <file> left untouched instead of Saved:. --strict is refused
together with --stream (streaming writes never recalculate, so the gate could never fire). Verbs
that produce no recalculation result — put/putf/fill/copy for the cell they authored, and
every presentation-only verb — cannot gate today (issue #504).
| Category | Commands | Purpose |
|---|---|---|
Info (no -f) |
functions, rasterizers, new |
Capability listing, blank workbook |
| Navigate | sheets, bounds, names |
Find your way around |
| Explore | view, cell, search, stats |
Read data incrementally |
| Analyze | eval, evala |
What-if formula evaluation (scalar + array) |
| Mutate cells | put, putf, style, fill, clear, copy, sort, merge, unmerge, comment, remove-comment, batch, import |
Make changes (require -o or -i) |
| Rows/columns | row, col, autofit, insert-rows, delete-rows, insert-cols, delete-cols |
Sizing, visibility, structural editing |
| Sheets & view | add-sheet, remove-sheet, rename-sheet, move-sheet, copy-sheet, sheets hide/show, freeze, unfreeze, name |
Workbook structure |
| Appearance & print | sheet-view, tab-color, page-setup, header-footer |
Deliverable finish: gridlines, zoom, tab colors, print setup, footers |
| Conditional formatting | cf add, cf list |
Highlight rules, color scales, data bars, top-N, text matches |
| Command | Arguments | Description |
|---|---|---|
functions |
List all 108 supported Excel functions (no -f needed) |
|
rasterizers |
List available SVG-to-raster backends (no -f needed) |
|
new |
<output> [--sheet <name>]... |
Create a blank xlsx file (no -f needed) |
sheets |
[list|hide <name> [--very]|show <name>] |
List sheets (default) or hide/show one |
names |
List defined names (named ranges) | |
name |
add <name> <refers-to> | rm <name> |
Manage named ranges (requires -o) |
bounds |
[--scan] |
Show used range of current sheet |
view |
<range> [flags] |
Render range (markdown/json/csv/html/svg/png/jpeg/webp/pdf) |
cell |
<ref> [--no-style] |
Get single cell details |
search |
<pattern> [--limit n] [--sheets a,b] |
Find cells matching pattern (regex, all sheets by default) |
stats |
<range> |
Statistics for numeric values in range |
filter |
--where <pred> [--columns A,C:E] [--limit n] [--format md|csv|json] [--header] |
Show rows matching a predicate (read-only) |
eval |
<formula> [--with overrides] |
Evaluate formula without modifying |
evala |
<formula> [--at <ref>] [--with overrides] |
Evaluate array formula; display or spill result grid |
put |
<ref|range> <value...> [--csv] [--no-detect] |
Write value(s) to cell or range (requires -o) |
putf |
<ref|range> <formula...> |
Write formula(s); single formula + range drags with $ anchors (requires -o) |
style |
<range> [options] |
Apply styling (requires -o) |
row |
<n> [--height pt] [--hide|--show] |
Set row properties (requires -o) |
col |
<letter|A:F> [--width n] [--auto-fit] [--hide|--show] |
Set column properties (requires -o) |
autofit |
[--columns A:F] |
Auto-fit column widths from content (requires -o) |
fill |
<source> <target> [--right] |
Fill cells with source value/formula (requires -o) |
clear |
<range> [--all|--styles|--comments] |
Clear cell contents/styles/comments (requires -o) |
copy |
<source> <target> [--values-only] |
Copy range with formula adjustment (requires -o) |
sort |
<range> --by <col> [options] |
Sort rows by one or more columns (requires -o) |
merge |
<range> |
Merge cells (requires -o) |
unmerge |
<range> |
Unmerge cells (requires -o) |
comment |
<ref> <text> [--author name] |
Add cell comment (requires -o) |
remove-comment |
<ref> |
Remove cell comment (requires -o) |
freeze |
<ref> |
Freeze panes at cell (requires -o) |
unfreeze |
Remove freeze panes (requires -o) |
|
sheet-view |
[--gridlines on|off] [--zoom n] [--tab-selected on|off] |
Set sheet view options (requires -o) |
tab-color |
<color> | --clear |
Set/clear the sheet tab color (requires -o) |
page-setup |
[--orientation portrait|landscape] [--scale n] [--fit-to-width n] [--fit-to-height n] [--fit-to-page on|off] |
Set print page setup (requires -o) |
header-footer |
[--odd-header s] [--odd-footer s] [--even-\*] [--first-\*] [--different-odd-even] [--different-first] |
Set print header/footer text (requires -o) |
cf add |
--range <range> --rule <dsl> [format flags] |
Add a conditional-formatting rule (requires -o) |
cf list |
List conditional-formatting rules on the sheet (read-only) | |
chart add |
--type <t> --data <range> --at <ref> [options] |
Add a chart built from sheet ranges (requires -o) |
add-image |
<image-file> --at <ref> [--size WxH] |
Embed an image (requires -o) |
import |
<csv-file> [start-ref] [options] |
Import CSV with type detection (requires -o) |
import-md |
<md-file|-> [--start ref] [options] |
Import GFM markdown table with type detection (requires -o) |
add-sheet |
<name> [--after s] [--before s] |
Add new empty sheet (requires -o) |
remove-sheet |
<name> |
Remove sheet (requires -o) |
rename-sheet |
<name> <new-name> |
Rename sheet (requires -o) |
move-sheet |
<name> [--to idx] [--after s] [--before s] |
Move sheet to new position (requires -o) |
copy-sheet |
<name> <new-name> |
Copy sheet to new name (requires -o) |
insert-rows |
<at-row> [count] |
Insert rows; shifts cells, rewrites formulas (requires -o) |
delete-rows |
<at-row> [count] |
Delete rows; #REF! on lost references (requires -o) |
insert-cols |
<at-col> [count] |
Insert columns; shifts cells, rewrites formulas (requires -o) |
delete-cols |
<at-col> [count] |
Delete columns; #REF! on lost references (requires -o) |
batch |
<file|-> [--dry-run] |
Apply multiple operations from JSON (requires -o; --dry-run validates without a file) |
diff |
-g <file2> [--format markdown|json] |
Compare two workbooks; exit 0 identical, 1 differs, 2 error |
lint |
[<file>] [--format text|json] |
Validate package structure (child order, r:id resolution, content-type coverage, over-max refs, data-table integrity, <f> canon); positional file or -f; exit 0 clean, 1 findings, 2 error |
Sheet operations. With no subcommand, defaults to list.
Subcommands:
| Subcommand | Arguments | Description |
|---|---|---|
list |
[--stats] |
List all sheets (--stats adds cell/formula counts; slower) |
hide |
<sheet-name> [--very] |
Hide a sheet (--very = very hidden, VBA-only; requires -o) |
show |
<sheet-name> |
Show a hidden sheet (requires -o) |
Output (list --stats):
| # | Name | Range | Cells | Formulas |
|---|-------------|----------|-------|----------|
| 1 | Assumptions | A1:F50 | 234 | 12 |
| 2 | Revenue | A1:M100 | 892 | 156 |
| 3 | P&L | A1:N120 | 978 | 76 |List and manage defined names (named ranges).
xl -f model.xlsx names # List all defined names
xl -f model.xlsx -o out.xlsx name add Tax 'Sheet1!$A$1' # Add or replace
xl -f model.xlsx -o out.xlsx name rm Tax # RemoveShow the used range (bounding box of non-empty cells) for the sheet selected with -s. Instant by default (reads the worksheet's dimension element); --scan forces a full streaming scan for accurate bounds.
Output:
Sheet: Revenue
Used range: A1:M100
Rows: 1-100 (100 total)
Columns: A-M (13 total)
Non-empty: 892 cells
View a rectangular range — markdown table by default, or JSON/CSV/HTML/SVG/PNG/JPEG/WebP/PDF.
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
range |
string | Yes | — | Cell range (e.g., "A1:D20") |
--format |
string | No | markdown | Output format: markdown, json, csv, html, svg, png, jpeg, webp, pdf |
--formulas |
flag | No | false | Show formulas instead of values |
--eval |
flag | No | false | Evaluate formulas (compute live values) |
--strict |
flag | No | false | Fail on formula evaluation errors (with --eval) |
--limit |
int | No | 50 | Max rows to display (0 = no limit). When output is clipped, a truncation marker is reported: markdown appends a "… showing X of Y rows" trailer; json adds truncated/totalRows fields (with --stream the notice goes to stderr instead); csv/svg note on stderr; html notes on stderr and appends an HTML comment; raster formats append the notice to the Exported: line |
--skip-empty |
flag | No | false | Skip empty cells (JSON) or empty rows/columns (tabular) |
--skip-hidden |
flag | No | false | Omit hidden rows/columns. Default renders them — a range you named never silently loses cells (GH-474) |
--show-labels |
flag | No | false | Include column letters and row numbers |
--header-row |
int | No | — | Use values from this row as keys in JSON output (1-based) |
--raster-output |
path | For raster | — | Output file (required for png/jpeg/webp/pdf) |
--dpi |
int | No | 144 | Resolution for raster output |
--quality |
int | No | 90 | JPEG quality 1-100 |
--rasterizer |
string | No | batik | PNG/JPEG export uses Apache Batik by default (pure JVM, no external tools). Force another backend: cairosvg, rsvg-convert, resvg, imagemagick (explicit opt-in since 0.11.3). Native binaries need an external backend — see xl rasterizers |
--gridlines |
flag | No | false | Show cell gridlines in SVG output |
--print-scale |
flag | No | false | Apply print scaling (for PDF-like output) |
Output:
| | A | B | C | D |
|---|-------------|---------|------------|---------|
| 1 | Revenue | | $1,000,000 | |
| 2 | COGS | | $400,000 | |
| 4 | Gross Profit| | =C1-C2 | |Hidden rows and columns (GH-474): a range you addressed explicitly renders in full — hidden lines included — and every data format carries a marker:
| Format | Marker |
|---|---|
| markdown | trailing note: range includes hidden row(s) … and column(s) … line |
| csv | the same note on stderr (stdout stays machine-parseable) |
| json | top-level "hiddenRows": [5] / "hiddenCols": ["C"] fields (emitted only when the range holds hidden lines) |
--skip-hidden restores the visible-only view, and the same marker then names what was dropped
(note: omitted hidden …). html/svg/png/jpeg/webp/pdf are pictures of the sheet: they
mirror Excel's display and always omit hidden lines. Streaming (--stream) never read row/column
properties, so it has always rendered every addressed cell — and for the same reason it cannot
honour --skip-hidden or emit the hidden-line marker: passing --skip-hidden with --stream
prints note: --skip-hidden is ignored with --stream … on stderr and renders everything. Drop
--stream when you need hidden lines elided or flagged.
Why the default: xl search finds a value in a hidden row and xl cell C5 reads it, so a view
that silently elided the same cell read as file corruption.
List SVG-to-raster backends with live availability on this machine (no -f needed). PNG/JPEG/WebP/PDF export probes backends in this order: batik → cairosvg → rsvg-convert → resvg; imagemagick is never probed automatically (fragile SVG delegate) and must be forced with --rasterizer imagemagick.
Platform matrix:
| Distribution | Rasterization |
|---|---|
JAR (java -jar, make install-jar) |
Works out of the box — Batik is bundled (pure JVM, needs AWT) |
Native binary (GitHub releases, make install) |
Batik cannot work (no AWT under native-image, by design) — one external tool is required |
External tool installs (any one is enough):
pip install cairosvg # Python, most portable
apt install librsvg2-bin # rsvg-convert (Debian/Ubuntu); brew install librsvg (macOS)
cargo install resvg # or a prebuilt binary: github.com/linebender/resvg/releasesWhen no backend is available, raster exports fail with an error naming the probed chain and pointing back at xl rasterizers. --format svg always works (pure vector, no backend needed).
Get complete information about a single cell.
Arguments:
| Arg | Type | Required | Description |
|---|---|---|---|
ref |
string | Yes | Cell reference (e.g., "A1", "B5") |
Output (formula cell):
Cell: C4
Type: Formula
Formula: =C1-C2
Cached Value: 600000
Formatted: $600,000
Output (value cell):
Cell: A1
Type: Text
Value: Revenue
Find cells containing text matching pattern. Searches all sheets by default (no -s needed).
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
pattern |
string | Yes | — | Search pattern (supports regex) |
--sheets |
string | No | all | Comma-separated list of sheets to search |
--limit |
int | No | 50 | Max results (0 = no limit). Reports the true total ("Found Y matches") and appends a "… showing X of Y matches" trailer when the hit list is clipped |
Output:
Found 5 matches for "Revenue":
| Ref | Value | Context (row) |
|-----|-----------------|----------------------------|
| A1 | Revenue | Revenue | | $1,000,000 |
| A10 | Revenue Growth | Revenue Growth | | 5% |Evaluate a formula without modifying the file (what-if analysis). -f is optional for constant formulas (xl eval "=PI()*2").
Arguments:
| Arg | Type | Required | Description |
|---|---|---|---|
formula |
string | Yes | Formula to evaluate |
--with, -w |
string | No | Temporary cell overrides (e.g., "B1=100,B2=200"; repeatable) |
Examples:
xl -f model.xlsx -s Sheet1 eval "=SUM(B1:B10)"
xl -f model.xlsx -s Sheet1 eval "=B1*1.1" --with "B1=100"Evaluate an array formula and display the result grid, or spill it into the sheet. Requires -f (array formulas need sheet context).
Arguments:
| Arg | Type | Required | Description |
|---|---|---|---|
formula |
string | Yes | Array formula to evaluate |
--at |
string | No | Target cell for array spill (default: display only) |
--with, -w |
string | No | Temporary cell overrides (repeatable) |
Examples:
xl -f data.xlsx -s Sheet1 evala "=TRANSPOSE(A1:C2)" # Display result grid
xl -f data.xlsx -s Sheet1 evala "=SEQUENCE(5)" --at E1 # Spill starting at E1
xl -f data.xlsx -s Sheet1 evala "=A1:B2*10" # Array arithmetic with broadcastingCalculate statistics (count, sum, min, max, average, ...) for numeric values in a range. Supports --stream for large files.
xl -f data.xlsx -s Sheet1 stats B2:B10000
xl -f huge.xlsx --stream stats A1:E100000Show rows of the used range matching a predicate. Read-only (no -o); phase 1 of GH-134 — predicate filtering only, no SQL-style SELECT/GROUP BY.
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
--where |
string | Yes | — | Filter predicate (grammar below) |
--columns |
string | No | all used | Output columns, e.g. A,C:E |
--limit |
int | No | 50 | Max matching rows to display |
--format |
string | No | markdown | markdown, csv, or json |
--header |
flag | No | false | First used row holds column names (excluded from matching) |
Predicate grammar (keywords case-insensitive; NOT > AND > OR, parens allowed):
| Form | Example |
|---|---|
Comparison (= != <> > >= < <=) |
B > 100, A = 'Widget', C = TRUE |
Wildcard match (% only) |
A LIKE 'Widget%' |
| Inclusive range | B BETWEEN 10 AND 100 |
| Set membership | A IN ('x', 'y', 'z') |
| Blank test | A IS EMPTY, A IS NOT EMPTY |
Semantics:
- Column refs are letters (
A,B) or, with--header, header names from the first used row (case-insensitive; header names win over letters on collision) - Numbers compare numerically, strings case-insensitively, booleans against
TRUE/FALSE - Type mismatch (e.g. text cell vs number literal) means the row doesn't match — never an error
- Formula cells compare by their cached value;
IS EMPTYis true for missing cells, empty cells, and blank text
xl -f sales.xlsx -s Q1 filter --where "Revenue > 10000 AND Region = 'EMEA'" --header
xl -f data.xlsx -s Sheet1 filter --where "A LIKE 'Widget%'" --columns A,C:E --format csv
xl -f data.xlsx -s Sheet1 filter --where "B BETWEEN 10 AND 99" --format jsonOutput: matching rows keep their original row numbers. Markdown adds a Row column and a match-count footer; CSV starts with a row,<labels> header line; JSON is an array of {"row": n, "cells": {<label>: <typed value>}} objects (labels are header names with --header, letters otherwise).
Limitations: loads the workbook in memory (--max-size envelope applies); --stream is not supported. No date literals in predicates yet.
Write value(s) to a cell or range.
Modes:
| Mode | Example | Behavior |
|---|---|---|
| Single | put A1 100 |
Write 100 to A1 |
| Fill | put A1:A10 "TBD" |
Fill range with the same value |
| Batch | put A1:C1 "X" "Y" "Z" |
One value per cell (row-major) |
| CSV split | put A1:C1 "X,Y,Z" --csv |
Split one comma-separated value across the range |
Type Inference:
- Numbers and formatted numbers:
1000,$1,234.56,50% - ISO dates:
2024-01-15 - Booleans:
true,false - Text: Everything else
Use --no-detect to preserve all positional values as text, including numbers and ISO date-like
strings.
Negative numbers: use the --value flag (a leading - is parsed as a flag):
xl -f input.xlsx -s S1 -o output.xlsx put A1 --value "-500"Example:
xl -f input.xlsx -s S1 -o output.xlsx put B5 1000000Write formula(s) to a cell or range with Excel-style dragging.
Modes:
| Mode | Example | Behavior |
|---|---|---|
| Single | putf C1 "=A1+B1" |
One formula, one cell |
| Drag | putf B2:B10 "=A2*1.1" |
Single formula + range: references shift per cell ($ anchors pin) |
| Batch | putf D1:D3 "=A1+B1" "=A2*B2" "=A3-B3" |
One formula per cell, applied as-is (no dragging) |
Anchor modes ($ controls shifting when dragging): $A$1 absolute, $A1 column-absolute, A$1 row-absolute, A1 fully relative.
Examples:
xl -f input.xlsx -s S1 -o output.xlsx putf C5 "=B5*1.1"
xl -f input.xlsx -s S1 -o output.xlsx putf C2:C10 "=SUM(\$B\$2:B2)" # Running total(The batch JSON putf op additionally accepts a "from" field to drag from an explicit source cell.)
Formula records (GH-430): legacy CSE array formulas ({=...}) and Data Table cells read from a file
survive all rewrites — view --formulas and cell render them braced ({=SUM(A1:A3*10)},
{=TABLE(A1,A2)}) and JSON output carries an additive "formulaKind": "array" | "dataTable" field.
putf rejects a top-level TABLE( expression: TABLE(...) is a data-table record's derived display
text, not a real function (Excel would show #NAME?); data-table authoring is tracked in GH-419.
Writing any value or formula onto a record cell replaces the record; copy of a data-table cell
pastes its cached constant (Excel's paste behavior) and copy of an array anchor pastes a plain
shifted formula.
Apply styling to cells.
Styles merge with existing formatting by default; --replace overwrites.
Arguments:
| Arg | Type | Description |
|---|---|---|
range |
string | Cell/range reference |
--bold |
flag | Bold text |
--italic |
flag | Italic text |
--underline |
flag | Underlined text |
--font-size |
double | Font size in points |
--font-name |
string | Font family (e.g., "Arial") |
--bg |
string | Background color (name, #hex, or rgb(r,g,b)) |
--fg |
string | Text color |
--align |
string | Horizontal alignment: left, center, right |
--valign |
string | Vertical alignment: top, middle, bottom |
--wrap |
flag | Enable text wrapping |
--format |
string | Number format: general, number, currency, percent, date, text |
--border |
string | Border style for all sides: none, thin, medium, thick |
--border-top / --border-right / --border-bottom / --border-left |
string | Per-side border style |
--border-color |
string | Border color (applies to all specified borders) |
--replace |
flag | Replace entire style instead of merging |
Example:
xl -f input.xlsx -s S1 -o output.xlsx style A1:D1 --bold --bg yellow --align centerSet row properties (height, hide/show).
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
n |
int | Yes | — | Row number (1-based) |
--height |
double | No | — | Row height in points |
--hide |
flag | No | false | Hide the row |
--show |
flag | No | false | Show (unhide) the row |
Examples:
xl -f input.xlsx -o output.xlsx row 5 --height 30
xl -f input.xlsx -o output.xlsx row 10 --hide
xl -f input.xlsx -o output.xlsx row 10 --showSet column properties (width, hide/show, auto-fit).
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
letter |
string | Yes | — | Column letter or range (e.g., "A", "AA", "A:F") |
--width |
double | No | — | Column width in character units (~8.43 default) |
--auto-fit |
flag | No | false | Auto-fit width based on cell content |
--hide |
flag | No | false | Hide the column |
--show |
flag | No | false | Show (unhide) the column |
Behavior:
--auto-fitcalculates optimal width based on longest cell content in the column- Adds 2 characters of padding to the calculated width
- Minimum width is 8.43 (Excel default)
- If both
--widthand--auto-fitare specified,--auto-fittakes precedence
Examples:
# Set explicit width
xl -f input.xlsx -o output.xlsx col B --width 20
# Auto-fit column width based on content
xl -f input.xlsx -o output.xlsx col A --auto-fit
# Hide/show columns
xl -f input.xlsx -o output.xlsx col C --hide
xl -f input.xlsx -o output.xlsx col C --showClear cell contents, styles, or comments from a range.
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
range |
string | Yes | — | Cell or range reference (e.g., "A1", "A1:D10") |
--all |
flag | No | false | Clear contents, styles, and comments |
--styles |
flag | No | false | Clear styles only (reset to default) |
--comments |
flag | No | false | Clear comments only |
Behavior:
- Default (no flags): Clears cell contents only
--all: Clears everything (contents, styles, comments)--styles: Clears formatting but keeps contents and comments--comments: Clears comments but keeps contents and styles- Flags can be combined:
--styles --commentsclears both - Merged regions overlapping the cleared range are automatically unmerged
Examples:
# Clear contents (default)
xl -f input.xlsx -o output.xlsx clear A1:D10
# Clear everything
xl -f input.xlsx -o output.xlsx clear A1:D10 --all
# Clear styles only (keep data)
xl -f input.xlsx -o output.xlsx clear A1:D10 --styles
# Clear comments only
xl -f input.xlsx -o output.xlsx clear B5 --comments
# Clear styles and comments, keep contents
xl -f input.xlsx -o output.xlsx clear A1:D10 --styles --commentsFill cells with source value/formula (Excel Ctrl+D/Ctrl+R equivalent).
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
source |
string | Yes | — | Source cell or range (e.g., "A1", "A1:C1") |
target |
string | Yes | — | Target range to fill (e.g., "A1:A10", "A1:C10") |
--right |
flag | No | false | Fill rightward instead of downward |
Behavior:
- Fill Down (default): Source row(s) are repeated down through target range
- Columns must match between source and target
- Example:
fill A1 A1:A10copies A1 to A2:A10 - Example:
fill A1:C1 A1:C10copies row 1 to rows 2-10
- Fill Right (
--right): Source column(s) are repeated right through target range- Rows must match between source and target
- Example:
fill A1 A1:E1 --rightcopies A1 to B1:E1 - Example:
fill A1:A5 A1:E5 --rightcopies column A to columns B-E
- Formulas are shifted using Excel anchor rules (
$anchors are preserved)
Examples:
# Fill value down a column (Ctrl+D equivalent)
xl -f input.xlsx -o output.xlsx fill A1 A1:A100
# Fill multiple columns down together
xl -f input.xlsx -o output.xlsx fill A1:E1 A1:E100
# Fill value right across a row (Ctrl+R equivalent)
xl -f input.xlsx -o output.xlsx fill A1 A1:J1 --right
# Fill multiple rows right together
xl -f input.xlsx -o output.xlsx fill A1:A5 A1:J5 --right
# Formula shifting example: =A1*2 in B1 fills to =A2*2, =A3*2, etc.
xl -f input.xlsx -o output.xlsx fill B1 B1:B10Auto-fit column widths based on content (defaults to all used columns).
xl -f input.xlsx -s S1 -o output.xlsx autofit
xl -f input.xlsx -s S1 -o output.xlsx autofit --columns A:FCopy a range to another location with Excel-style formula adjustment ($ anchors preserved). --values-only copies values without adjusting formulas.
xl -f input.xlsx -s S1 -o output.xlsx copy A1:C10 E1
xl -f input.xlsx -s S1 -o output.xlsx copy A1:C10 E1 --values-onlySort rows in a range by one or more columns.
Arguments:
| Arg | Type | Required | Description |
|---|---|---|---|
range |
string | Yes | Range to sort |
--by, -b |
string | Yes | Primary sort column |
--then-by |
string | No | Secondary sort column(s) (repeatable) |
--desc |
flag | No | Sort descending (default: ascending) |
--numeric |
flag | No | Force numeric comparison ("10" > "9") |
--header |
flag | No | First row is header (exclude from sort) |
Behavior: empty cells sort last; formulas sort by cached value; rows move together.
xl -f f.xlsx -s S1 -o o.xlsx sort A1:D100 --by B --desc --numeric
xl -f f.xlsx -s S1 -o o.xlsx sort A1:D100 --by B --then-by C --headerMerge or unmerge cells in a range.
xl -f input.xlsx -s S1 -o output.xlsx merge A1:D1
xl -f input.xlsx -s S1 -o output.xlsx unmerge A1:D1Add or remove a cell comment.
xl -f input.xlsx -s S1 -o output.xlsx comment A1 "Verify this figure" --author "Analyst"
xl -f input.xlsx -s S1 -o output.xlsx remove-comment A1Freeze panes at a cell reference (rows above and columns to the left are locked), or remove them.
xl -f input.xlsx -s S1 -o output.xlsx freeze B2 # Freeze row 1 + column A
xl -f input.xlsx -s S1 -o output.xlsx unfreezeThe "deliverable finish" commands (GH-358). Each merges into the sheet's current settings:
unspecified options are preserved. All require -o (or -i).
# Gridlines off + 85% zoom
xl -f in.xlsx -s Model -o out.xlsx sheet-view --gridlines off --zoom 85
# Tab colors: named, #hex, rgb(r,g,b), or theme:<slot>[:<tint>]
xl -f in.xlsx -s Model -o out.xlsx tab-color "#1F4E79"
xl -f in.xlsx -s Model -o out.xlsx tab-color theme:accent2:0.25
xl -f in.xlsx -s Model -o out.xlsx tab-color --clear # clears a modeled color only
# Landscape, fit to one page wide and tall
xl -f in.xlsx -s Model -o out.xlsx page-setup --orientation landscape \
--fit-to-width 1 --fit-to-height 1
# Confidential footer (&L/&C/&R sections; &P page, &N total, &D date, &F file, &A sheet)
xl -f in.xlsx -s Model -o out.xlsx header-footer \
--odd-footer "&LProprietary & Confidential&RPage &P of &N"Options:
| Command | Options |
|---|---|
sheet-view |
--gridlines on|off, --zoom <10-400>, --tab-selected on|off |
tab-color |
<color> or --clear |
page-setup |
--orientation portrait|landscape, --scale <10-400>, --fit-to-width <n>, --fit-to-height <n>, --fit-to-page on|off |
header-footer |
--odd-header/--odd-footer, --even-header/--even-footer, --first-header/--first-footer, --different-odd-even, --different-first |
Notes:
- Validation is up-front with clean errors (zoom/scale 10-400, orientation values, fit counts >= 1).
tab-color --clearremoves the modeled color; a tab color already present in the source file's XML is preserved on write (preserve-if-None semantics) and cannot be stripped by the CLI.page-setup --fit-to-pageis tri-state: omitted derives the sheetPrfitToPageflag from--fit-to-width/--fit-to-heightand preserves whatever the source carries;onforces the flag;offactively strips a preserved flag.- Even-page text sets
different-odd-evenautomatically, first-page text setsdifferent-first(Excel ignores the text while the corresponding flag is off). - Each command has a batch-op twin (
sheet-view,tab-color,page-setup,header-footer) — the full deliverable finish is one batch file:
cat > finish.json <<'EOF'
[
{"op": "sheet-view", "gridlines": false, "zoom": 85},
{"op": "tab-color", "color": "#1F4E79"},
{"op": "page-setup", "orientation": "landscape", "fitToWidth": 1, "fitToHeight": 1},
{"op": "header-footer", "oddFooter": "&LProprietary & Confidential&RPage &P of &N"}
]
EOF
xl -f in.xlsx -s Model -o out.xlsx batch finish.jsonAuthor conditional formatting (GH-324). cf add appends one rule to a range (requires -o);
cf list shows the sheet's rules (read-only). Priorities are auto-assigned in add order
(lower priority wins in Excel) — the CLI never hand-stamps them.
Rule DSL (--rule):
| Family | Syntax | Example |
|---|---|---|
| Cell value | cellIs:<op>:<value> |
cellIs:greaterThan:100 (ops: lessThan/lt, lessThanOrEqual/lte, equal/eq, notEqual/ne, greaterThanOrEqual/gte, greaterThan/gt) |
| Range | between:<lo>:<hi>, notBetween:<lo>:<hi> |
between:10:100 |
| Formula | expression:<formula> |
expression:MOD(ROW(),2)=0 (formula may contain :) |
| Color scale | colorScale:<c1>:<c2>[:<c3>] |
colorScale:red:white:green (3-point mid at 50th percentile) |
| Data bar | dataBar:<color> |
dataBar:#638EC6 |
| Top/bottom N | top10:<n>[:percent], bottom10:<n>[:percent] |
top10:5:percent |
| Text match | text:<op>:<s> |
text:contains:overdue (ops: contains, notContains, beginsWith, endsWith; <s> may contain :) |
Format flags (highlight rules — cellIs, between, notBetween, expression, top10,
bottom10, text — require at least one; colorScale/dataBar carry inline colors and reject
them): --bold, --italic, --underline, --strike, --bg <color>, --fg <color>.
Flag colors accept the full color syntax including theme:accent1[:tint]; color tokens inside
colorScale:/dataBar: rule strings accept named/#hex/rgb(r,g,b) only (the : separator
conflicts with theme syntax).
# Red highlight for values over 100
xl -f f.xlsx -s S1 -o o.xlsx cf add --range A1:A10 \
--rule 'cellIs:greaterThan:100' --bold --bg '#FFC7CE' --fg '#9C0006'
# 3-point color scale, then inspect
xl -f f.xlsx -s S1 -o o.xlsx cf add --range B2:B20 --rule 'colorScale:red:white:green'
xl -f o.xlsx -s S1 cf listBatch op cf mirrors the command:
{"op": "cf", "range": "A1:A10", "rule": "cellIs:greaterThan:100", "bold": true, "bg": "#FFC7CE"}Add a typed chart built from sheet data ranges. Supported types: column, bar (horizontal),
line, pie.
# Column chart: one series per data column, categories down column A
xl -f in.xlsx -s Data -o out.xlsx chart add --type column \
--data B2:D10 --categories A2:A10 --series-names "Q1,Q2,Q3" \
--title "Revenue" --at F2:K15
# Stacked horizontal bars, no legend, single-cell placement (default ~5.6x2.8cm size)
xl -f in.xlsx -s Data -o out.xlsx chart add --type bar --grouping stacked \
--data B2:D10 --legend none --at F2
# Pie over one series
xl -f in.xlsx -s Data -o out.xlsx chart add --type pie \
--data B2:B6 --categories A2:A6 --at E2:J12| Flag | Description |
|---|---|
--type, -t |
column, bar, line, pie (required) |
--grouping |
clustered (default), stacked, percent-stacked — column/bar only |
--data |
Values range; qualified refs (Data!B2:D10) accepted (required) |
--categories |
Categories vector (one row or one column) |
--series-names |
Comma-separated literal names, applied positionally |
--series-colors |
Comma-separated colors (#307FE2,#005670), applied positionally; unset series cycle the theme accents. Pie: colors map per slice (c:dPt), slices past the list continue the accent cycle |
--title |
Chart title |
--legend |
right (default), left, top, bottom, top-right, none |
--at |
Placement: a range (chart stretches over it) or a single cell (required) |
Series split (deterministic): orientation follows the categories vector — column categories
(A2:A10) make one series per data column, row categories (B1:D1) one per data row,
absent categories default to per-column. A dimension mismatch between categories and data is an
error, never a guess. Pie charts require exactly one series.
Embed an image. PNG/JPEG/GIF/BMP get natural-size sniffing for a single-cell --at; TIFF/EMF/WMF
need an explicit --size. A range --at stretches the image over the range.
xl -f in.xlsx -s S1 -o out.xlsx add-image logo.png --at B2 # natural size
xl -f in.xlsx -s S1 -o out.xlsx add-image logo.png --at B2 --size 320x240
xl -f in.xlsx -s S1 -o out.xlsx add-image banner.jpeg --at A1:F4 # stretch over rangeImport CSV data with automatic type detection (numbers, booleans, ISO dates).
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
csv-file |
string | Yes | — | CSV file path |
start-ref |
string | No | A1 | Cell where import starts |
--delimiter |
char | No | , |
Field separator |
--encoding |
string | No | UTF-8 | Input encoding |
--skip-header |
flag | No | false | Skip first row (do not import) |
--new-sheet |
string | No | — | Create new sheet for imported data |
--no-type-inference |
flag | No | false | Treat all values as text |
xl -f f.xlsx -s S1 -o o.xlsx import data.csv A1 --delimiter ";" --skip-header
xl -f f.xlsx -o o.xlsx import data.csv --new-sheet "Imported"Limitations: entire CSV is loaded into memory (recommended <50k rows); dates must be ISO 8601 (YYYY-MM-DD).
Import a GFM (GitHub Flavored Markdown) pipe table with smart type detection. Use - to read from stdin — handy for LLM agents that generate tables inline.
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
md-file |
string | Yes | — | Markdown file path, or - for stdin |
--start |
string | No | A1 | Top-left cell for the imported table |
--skip-header |
flag | No | false | Skip the table's header row (do not import) |
--new-sheet |
string | No | — | Create new sheet for imported data |
--no-type-inference |
flag | No | false | Treat all values as text |
xl -f f.xlsx -s S1 -o o.xlsx import-md table.md --start B2
xl -f f.xlsx -o o.xlsx import-md table.md --new-sheet "Data"
printf '| A | B |\n|---|---|\n| 1 | 2 |\n' | xl -f f.xlsx -s S1 -o o.xlsx import-md -Table format (GFM): header row, delimiter row (|---|---|), body rows. The first table found in the input is imported (preamble prose is skipped); the table ends at the first blank line. Outer pipes are optional, \| inside a cell is a literal pipe, and cell whitespace is trimmed. Body rows are padded/truncated to the delimiter row's column count.
Type detection (per cell, same smart detection as batch put): currency $1,234.56 → Number + Currency format, percent 45.5% → 0.455 + Percent format, ISO dates 2025-01-15 → date-formatted cell, plain numbers and true/false → typed values, everything else → text. Opt out with --no-type-inference.
Alignment: GFM markers map to cell horizontal alignment — :--- left, :---: center, ---: right (no marker leaves alignment unset).
Limitations: input is read as UTF-8 and parsed in memory; one table per import (first wins).
xl -f f.xlsx -o o.xlsx add-sheet Summary --after Sheet1 # or --before <name>
xl -f f.xlsx -o o.xlsx remove-sheet Scratch
xl -f f.xlsx -o o.xlsx rename-sheet "Old Name" "New Name"
xl -f f.xlsx -o o.xlsx move-sheet Summary --to 0 # or --after/--before <name>
xl -f f.xlsx -o o.xlsx copy-sheet Template "Q2 Report"Insert or delete rows/columns with full formula-reference rewriting: references at or past the cut shift, straddling ranges shrink, and references to deleted cells become #REF!. Cross-sheet references to the edited sheet are rewritten too.
Arguments: position (<at-row> 1-based, or <at-col> letter) and optional <count> (default 1). Column commands also accept an inclusive range (C:E), which overrides the count.
xl -f f.xlsx -s S1 -o o.xlsx insert-rows 5 2 # Insert 2 rows at row 5
xl -f f.xlsx -s S1 -o o.xlsx delete-rows 5 # Delete row 5
xl -f f.xlsx -s S1 -o o.xlsx insert-cols B 3 # Insert 3 columns at B
xl -f f.xlsx -s S1 -o o.xlsx delete-cols C:E # Delete columns C through EExample formula rewriting: deleting row 2 turns =A1+A3 into =A1+A2; =SUM(A1:A4) shrinks to =SUM(A1:A3); a direct reference to a deleted cell becomes #REF!.
Apply multiple operations atomically from JSON input.
Arguments:
| Arg | Type | Required | Description |
|---|---|---|---|
file |
string | No (default -) |
JSON file path or - for stdin |
--dry-run |
flag | No | Validate JSON and show a summary without writing (works without -f/-o) |
JSON Schema:
[
{"op": "put", "ref": "A1", "value": "Hello"},
{"op": "putf", "ref": "B1", "value": "=A1*2"},
{"op": "style", "range": "A1:B1", "bold": true},
{"op": "merge", "range": "A1:D1"},
{"op": "colwidth", "col": "A", "width": 15.5},
{"op": "rowheight", "row": 1, "height": 30},
{"op": "comment", "ref": "A1", "text": "Revenue figure", "author": "Analyst"},
{"op": "autofit", "columns": "A:D"},
{"op": "add-sheet", "name": "Summary", "after": "Sheet1"}
]Supported Operations:
| Operation | Required Fields | Optional Fields | Description |
|---|---|---|---|
put |
ref, value |
format, values, detect |
Write value to cell |
putf |
ref, value |
from, values, format |
Write formula(s) to cell(s); format applies a number format to the formula cell(s) |
style |
range |
styling options | Apply cell styling |
merge |
range |
Merge cells | |
unmerge |
range |
Unmerge cells | |
colwidth |
col, width |
Set column width | |
rowheight |
row, height |
Set row height | |
comment |
ref, text |
author |
Add cell comment |
remove-comment |
ref |
Remove cell comment | |
hyperlink |
ref |
target |
Set cell hyperlink (omit target to clear) |
clear |
range |
all, styles, comments |
Clear cell contents/styles/comments |
col-hide |
col |
Hide column | |
col-show |
col |
Show column | |
row-hide |
row |
Hide row | |
row-show |
row |
Show row | |
autofit |
columns |
Auto-fit column widths | |
add-sheet |
name |
after |
Add new sheet |
rename-sheet |
from, to |
Rename sheet | |
freeze |
ref |
Freeze panes at cell | |
unfreeze |
Remove freeze panes | ||
copy |
source, target |
valuesOnly |
Copy range with formula adjustment |
sheet-view |
gridlines, zoom, tabSelected |
Set sheet view options (operates on --sheet) |
|
tab-color |
color, clear |
Set (color) or clear (clear: true) the sheet tab color |
|
page-setup |
orientation, scale, fitToWidth, fitToHeight, fitToPage |
Set print page setup | |
header-footer |
oddHeader, oddFooter, evenHeader, evenFooter, firstHeader, firstFooter, differentOddEven, differentFirst |
Set print header/footer text | |
cf |
range, rule |
bold, italic, underline, strike, bg, fg |
Add a conditional-formatting rule (see cf add) |
chart |
type, data, at |
categories, seriesNames, seriesColors, title, legend, grouping |
Add a chart (mirrors chart add) |
Native JSON Types (recommended):
// Numbers are stored as numeric values (not text)
{"op": "put", "ref": "A1", "value": 99.0}
// Booleans
{"op": "put", "ref": "A2", "value": true}
// With explicit format
{"op": "put", "ref": "A3", "value": 99.0, "format": "currency"}
{"op": "put", "ref": "A4", "value": 0.594, "format": "percent"}Format Options:
| Format Name | Description | Example Output |
|---|---|---|
general |
Default format | 1234.5 |
integer |
Whole numbers | 1235 |
decimal |
Two decimal places | 1234.50 |
currency |
Currency with symbol | $1,234.50 |
percent |
Percentage | 59% |
percent_decimal |
Percentage with decimals | 59.4% |
date |
Date format | 11/10/25 |
datetime |
Date and time | 11/10/25 14:30 |
time |
Time only | 14:30:00 |
text |
Text format | 1234.5 |
| custom | Any Excel format code | See below |
Custom Format Codes:
// MOIC/Multiple format (3.5x)
{"op": "put", "ref": "A1", "value": 3.5, "format": "0.0x"}
// Accounting format with negatives in parentheses
{"op": "put", "ref": "A2", "value": -1234, "format": "$#,##0;($#,##0)"}
// Basis points
{"op": "put", "ref": "A3", "value": 50, "format": "0 \"bps\""}
// Custom date format
{"op": "put", "ref": "A4", "value": "2025-11-10", "format": "yyyy-mm-dd"}
// Quoted-literal / semicolon-only codes are codes too (the 1/0 toggle-flag idiom)
{"op": "put", "ref": "A5", "value": 1, "format": "\"Yes \";;\"No \""}Unrecognized format strings (GH-475): a string that is neither a known name nor Excel format-code-shaped is a typo far more often than a code.
- On the put/putf
formathint it is ignored, with a warning on stderr naming the string and listing the known names (format: "curency"→ warning, cell stays General). - On the
styleop'snumFormatit is still applied as a custom code (Excel, not xl, is the authority on codes) but warns the same way.
The --stream batch path applies exactly the same table, including custom codes — it used to know
only six names and dropped everything else in silence.
Smart String Detection (enabled by default):
Strings are automatically detected and formatted:
// Currency detected from $ prefix
{"op": "put", "ref": "A1", "value": "$99.00"} // → Number(99.0), Currency
// Percent detected from % suffix
{"op": "put", "ref": "A2", "value": "59.4%"} // → Number(0.594), Percent
// ISO date detected
{"op": "put", "ref": "A3", "value": "2025-11-10"} // → DateTime, Date format
// Plain text (no detection pattern)
{"op": "put", "ref": "A4", "value": "Hello"} // → TextDisable Detection: Set "detect": false to treat strings as plain text:
{"op": "put", "ref": "A1", "value": "$99.00", "detect": false} // → Text "$99.00"
{"op": "put", "ref": "A2", "value": "59.4%", "detect": false} // → Text "59.4%"Formula Dragging (putf with range):
// Single formula dragged across range (uses Excel $ anchoring)
{"op": "putf", "ref": "B2:B10", "value": "=SUM($A$1:A2)", "from": "B2"}
// Explicit formulas for each cell (no dragging)
{"op": "putf", "ref": "B2:B4", "values": ["=A2*2", "=A3*2", "=A4*2"]}Formula Number Formats (putf with format, parity with put):
The format field accepts the same named formats and custom codes as put and
applies the number format to the formula cell(s) — no second style pass needed.
Works with all three variants (single, dragging, explicit values):
{"op": "putf", "ref": "C1", "value": "=A1*2", "format": "#,##0.0"}
{"op": "putf", "ref": "B2:B10", "value": "=A2/A$1", "from": "B2", "format": "percent"}
{"op": "putf", "ref": "D1:D2", "values": ["=SUM(A:A)", "=SUM(B:B)"], "format": "currency"}Style Options:
| Option | Type | Description |
|---|---|---|
bold |
boolean | Bold text |
italic |
boolean | Italic text |
underline |
boolean | Underlined text |
bg |
string | Background color (hex: #FF0000) |
fg |
string | Font color (hex: #0000FF) |
fontSize |
number | Font size in points |
fontName |
string | Font family name |
align |
string | Horizontal alignment: left, center, right, justify |
valign |
string | Vertical alignment: top, middle, bottom |
wrap |
boolean | Enable text wrapping |
numFormat |
string | Number format (see Format Options above) |
border |
string | All borders: none, thin, medium, thick |
borderTop |
string | Top border style |
borderRight |
string | Right border style |
borderBottom |
string | Bottom border style |
borderLeft |
string | Left border style |
borderColor |
string | Border color (hex) |
replace |
boolean | Replace style instead of merge (default: false) |
Note: Use align for horizontal alignment, not halign. Unknown properties are ignored with a warning.
Examples:
# From file
xl -f input.xlsx -o output.xlsx batch operations.json
# From stdin (pipe)
echo '[{"op": "put", "ref": "A1", "value": 100, "format": "currency"}]' | \
xl -f input.xlsx -s Sheet1 -o output.xlsx batch -
# Complex workflow
cat <<'EOF' | xl -f input.xlsx -s Sheet1 -o output.xlsx batch -
[
{"op": "put", "ref": "A1", "value": "Revenue", "format": "text"},
{"op": "put", "ref": "B1", "value": 1000000, "format": "currency"},
{"op": "style", "range": "A1:B1", "bold": true, "bg": "#FFFF00"},
{"op": "merge", "range": "A1:B1"},
{"op": "colwidth", "col": "A", "width": 20}
]
EOF| Scenario | Behavior | Workaround |
|---|---|---|
Leading zeros: "00123" |
Smart detection converts to number 123 |
Use "detect": false to preserve as text |
Mixed patterns: "50 (50%)" |
First pattern wins (treated as text) | Use explicit "format" field |
values array length mismatch |
Error raised if array length ≠ range cell count | Ensure exact match |
| Percent as decimal | "59.4%" stored as 0.594 |
Excel displays correctly with percent format |
| Invalid custom formats | Accepted but may render incorrectly in Excel | Test format codes in Excel first |
--stream mode |
Supports formula dragging but not formula evaluation | Use non-streaming for --eval |
Compare two workbooks and report differences. The first file comes from the global -f, the second from -g/--file2. Optional global -s/--sheet restricts the comparison to one sheet.
Arguments:
| Arg | Type | Required | Default | Description |
|---|---|---|---|---|
-g, --file2 |
path | Yes | — | Second file to compare against |
--format |
string | No | markdown | markdown (human) or json (stable schema) |
Exit codes (diff-tool convention): 0 identical, 1 differences found, 2 error.
What is compared (per sheet, refs in A1, row-major order):
- Changed cells — value, formula text, and resolved style (
styleChangedboolean). Formula cells compare by formula text; cached values are derived and ignored. Styles compare resolved formatting (style id lookup), so equal formatting under different ids is not a difference. - Added / removed cells — a cell with Empty value, default style, and no hyperlink counts as absent.
- Sheets added / removed (by name).
- Merged ranges, comments, hyperlinks — separate added/removed/changed deltas per sheet.
xl -f old.xlsx diff -g new.xlsx # Markdown report
xl -f old.xlsx -s Sheet1 diff -g new.xlsx # One sheet only
xl -f old.xlsx diff -g new.xlsx --format json # Machine-readable
xl -f old.xlsx diff -g new.xlsx && echo "unchanged" # Exit-code drivenJSON schema (stable; sheets lists only sheets with differences):
{
"identical": false,
"sheetsAdded": [], "sheetsRemoved": [],
"sheets": [{
"name": "Sheet1",
"added": [{"ref": "D5", "value": "New", "formula": null}],
"removed": [{"ref": "E5", "value": "Old", "formula": null}],
"changed": [{"ref": "A5",
"before": {"value": "Revenue", "formula": null},
"after": {"value": "Total Revenue", "formula": null},
"styleChanged": false}],
"mergesAdded": [], "mergesRemoved": [],
"commentsAdded": [], "commentsRemoved": [], "commentsChanged": [],
"hyperlinksAdded": [], "hyperlinksRemoved": [], "hyperlinksChanged": []
}]
}Limitations: both workbooks load in memory (--max-size applies to each); no range-level filter yet.
Validate the raw package structure against the Excel-repair classes — the defects Excel
repairs loudly (repair dialog, content stripped) but every lenient reader, xl's own
read included, accepts silently. Lint inspects the raw zip parts, never the parsed
model, so nothing gets normalized before it's checked.
xl lint deliverable.xlsx # Positional file form
xl -f deliverable.xlsx lint # Flag form (equivalent)
xl -f deliverable.xlsx lint --format json # Stable schema for pipelines
xl -f deliverable.xlsx lint && echo "safe to send"What it flags (the complete LintCategory roster — a test pins this list against
LintCategory.slug, so it cannot drift):
child-order— child elements ofxl/workbook.xml(CT_Workbook) or a worksheet (CT_Worksheet) out of ECMA-376 schema sequence (e.g.<externalReferences>after<extLst>)unresolved-rel-id— anr:id(sheet, externalReference, pivotCache, drawing, legacyDrawing, hyperlink, tablePart, …) with no entry in the paired.relswrong-rel-type— ther:idresolves, but to a relationship of the wrong typemissing-part— the relationship target part is absent from the packagemissing-content-type— a present-and-referenced part has neither an<Override>nor a matching extension<Default>in[Content_Types].xmlref-out-of-bounds— aref/sqref/dimensiontoken past row 1048576 or column XFDdata-table-torn— a<f t="dataTable">record whose grid no longer holds together (missing record cell, inconsistent inputs, interior no longer matching the record)data-table-unseeded— an uncached data-table interior in acalcMode="autoNoTable"book: Excel does not recompute data tables on open, so the grid opens BLANKformula-leading-equals—<f>text stored with the display form's leading=(non-spec; strict readers like openpyxl misread it — re-writing the file with xl heals it)
Exit codes (diff-tool convention): 0 no findings · 1 findings reported · 2 error
(unreadable file, malformed core part).
xl lint is read-only — it never repairs or rewrites the file. xl's own output always
lints clean; use it as a pre-send self-check in agent pipelines that splice or post-process
workbooks.
Always include explicit references in output:
# Good - References visible
| | A | B |
|---|----------|---------|
| 1 | Revenue | $1M |
| 2 | COGS | $400K |Error: <ErrorType>
Location: <Context>
Details: <Human-readable explanation>
Suggestion: <How to fix>
Example:
Error: CircularReference
Location: B10
Details: Formula =A10+B10 creates cycle: B10 → A10 → B10
Suggestion: Use a different cell reference to break the cycle
- Quick Start Guide — Library usage
- Scripting Guide — When a task outgrows the CLI (loops, typed extraction, multi-file pipelines)
- Performance Guide — Streaming for large files
- GitHub Issues — Feature requests and bug reports