Skip to content
TwinScope0.3.10

The twinscope command line

Every engine the app has, from a terminal — one npx away. The same detection, the same normalisation notes and byte-identical reports, because the command line is not a reimplementation but a second host for the same code.

Beyond the appEdit this page
Try it nowbash
npx twinscope before.json after.json

No install, no browser download, no account. It is on npm as twinscope and needs Node 22.12 or newer — see installing it for the global install and the from-source build.

Usagetext
twinscope <before> <after> [options]
twinscope --repo <path> <ref> <ref> [options]

Either operand may be - to read that side from stdin (one side only).
Terminaltext
$ twinscope 03-json/before.json 03-json/after.json
before.json → after.json
Structural JSON diff · 0 ms

+3 added -1 removed ~8 modified
nodes: 29 · type change: ⚠ 1

• Compared objects as sets of keys (order ignored).
• Matched array items by identity, not index.

Counts first, then what the comparison actually did — the same explainability rule the app follows. This is real output, not an illustration: it is what the sample JSON pair prints today.

Real invocations#

Every one of these is a comparison from the sample set, run as written:

Comparing two files, two folders, two refs and two directories of screenshotsbash
twinscope 03-json/before.json 03-json/after.json
twinscope 14-folder/before 14-folder/after
twinscope --repo 15-git/orders-repo main feature/refunds
twinscope 16-visual-regression/baseline 16-visual-regression/current --engine visual
twinscope 08-api/contract/openapi.before.json 08-api/contract/openapi.after.json --fail-on-breaking

Detection works the same way it does in the app, so the first line gets a structural JSON comparison and the second gets a file-tree comparison without being told. The two exceptions are on this page: --engine visual (below), and --repo, which changes what the operands are.

Installing it#

It is on npm as twinscope, and the fastest way to try it is not to install it at all:

Run it once, or install itbash
npx twinscope before.json after.json

npm install -g twinscope

Node 22.12 or newer, and nothing else: no native modules, no post-install step, and no node_modules beside the binary — it is one bundled CommonJS file with a shebang, plus the pdfjs worker it loads for PDF comparisons. Six files, under a megabyte packed, and zero dependencies, because every library it uses is already inside that bundle.

You can still build it from source, which is what a contributor wants:

Building the binarybash
git clone https://github.com/codeAesthetic/twinscope
cd twinscope
npm install
npm run build:cli    # -> out/cli/index.js

Invoke that as node out/cli/index.js. CI examples on the continuous integration page use the explicit node form or npx, because a workflow should not depend on a link somebody made by hand.

Options#

OptionWhat it does
--jsonA machine-readable result on stdout
--md, --markdownThe Markdown report
--htmlThe self-contained HTML report
--patchA unified diff (text comparisons only)
--githubGitHub Actions annotations and a job summary — see CI
--out <file>Write the output to a file instead of stdout
--engine <id>Force an engine instead of letting detection choose
--repo <path>Treat both operands as git refs in this repository
--ignore-whitespace, -wIgnore whitespace-only changes
--ignore-case, -iIgnore case
--no-colorNever emit ANSI colour
-q, --quietPrint nothing and rely on the exit code
-h, --helpThe help text
-v, --versionThe version
--max-changes <n>Fail when more than n things changed
--max-diff <percent>Fail when the difference exceeds this percentage
--fail-on-breakingFail when a breaking API change is found

The last three are CI thresholds, and they change what the exit code means.

Two output formats at once is an error rather than something resolved by argument order, and anything beginning with a dash that is not a known option is a typo rather than a filename — guessing at either is worse than saying so. A missing value is caught too: --out --json a b will not write a file called --json.

Exit codes#

0The two inputs are the same — or, with a threshold, within it
1They differ — or, with a threshold, they exceed it
2Something went wrong

Colour#

Colour is on when a human is looking at it and off otherwise: it is disabled when stdout is a pipe or a file, when NO_COLOR is set to anything, and when you pass --no-color. So redirecting the summary into a file gives you the text, not escape sequences.

Reading a side from stdin#

- in either position reads that side from standard input:

One side piped inbash
cat new.json | twinscope old.json -

Only one side can be -, for the obvious reason, and git refs cannot come from stdin. The piped side is named stdin in the output so the report header reads as English, and it still goes through detection — piping JSON in gets you a JSON comparison.

Comparing two git refs#

--repo makes both operands refs inside one repository:

Two branches, and a branch against the files on diskbash
twinscope --repo . main HEAD
twinscope --repo . origin/main WORKTREE

WORKTREE is the sentinel for the files as they are on disk — git has no ref name for the working tree, so one had to be invented. Refs are validated before they reach a command line, because git diff --upload-pack=/bin/sh is a valid invocation and A..B turns two refs into a range; see the git engine for what that check allows.

Output formats#

FormatGoes toNotes
Summary (default)stdoutNames, engine, timing, counts, engine extras, normalisation notes
--jsonstdout, or --outThe summary, the two sides, the engine, the notes, identical, and the threshold verdict when one was set
--mdstdout, or --outThe app's Markdown report
--htmlstdout, or --outThe app's HTML report, self-contained
--patchstdout, or --outUnified diff; a non-text comparison gets Markdown instead

--json honours --out as well, which is exactly what a CI step wants. For a report format, --out writes the file and confirms it on stderr rather than stdout — so redirecting stdout somewhere still leaves you a message saying where the report went.

--json deliberately omits each engine's row data. It is a different shape per engine and, for a 50,000-row diff, megabytes of it. If you want the rows, you want a report.

Every engine, unchanged#

You can name any engine explicitly:

api · binary · csv · deps · env · folder · git · image · json · pdf · text · text-large · visual · web · xml · yaml

Forcing one is rarely needed — detection is the same code the app runs — with the exception of visual, which detection will never pick on purpose.

Why the visual engine has to be named#

Two folders of screenshots and two folders of source code are indistinguishable from outside. So a plain twinscope baseline/ current/ runs the fast folder comparison (hashes, renames) and --engine visual decodes every pair. One word saves decoding a repository by accident. See visual regression and the CI page for the thresholds.

That engine is also command-line only, and it says so if you reach it from the app: it needs to list directories and decode images, and neither of the app's processes can do both.

This is a second host, not a second implementation#

The reason a command line was a few hundred lines rather than a project is a rule the codebase has held since before it existed: the engines never import Electron. They are given a small set of host interfaces — read a file, decode an image, run a git command, read a PDF — and they never learn which host they are running in.

So the CLI supplies its own implementations of those interfaces and reuses every engine, the detection rules and both report renderers exactly as the app does. There is no second copy of a diff algorithm to keep in step.

A corollary: hosts explain themselves#

The CLI decodes PNG only. The app decodes whatever the operating system can. That difference belongs to the host, not to the engine — and getting it wrong is instructive.

The image engine used to hard-code its own failure message: "PNG, JPEG, WebP, GIF and AVIF". Correct in the app, and a lie in the terminal, where it advertised four formats the command line cannot read. The engine now passes the host's own explanation through instead, so the CLI says what it actually supports:

The refusal you get for a JPEG on the command linetext
twinscope: The command line can only decode PNG images. Compare other formats in the app.

Every screenshot tool and visual-regression runner writes PNG, so this costs almost nothing — and a clear refusal beats a silent wrong answer.

Normalisation is printed, not hidden#

Whatever the engine did to reach its answer is listed under the counts. "Explain what you did" applies to a terminal exactly as it does to the window: an anchored large-file comparison, resolved YAML merge keys, suppressed volatile headers, untracked files folded into a working-tree diff — all of it prints. See normalisation.

When an engine can offer a way out, the terminal offers it as a flag. Unparseable JSON in the app renders a Compare as text button; on the command line the error ends with Try --engine text.

What it does not do#

  • No network calls, in either direction. The CLI never fetches and never phones home. The desktop app's opt-in update check is not in this binary.
  • No progress bar. There is nothing to keep responsive, so the comparison runs inline; progress output would fight the report on stdout. Cancellation is whatever Ctrl+C already does.
  • No --accept flag for visual baselines. A diff tool that can overwrite one of its own inputs is one keystroke from destroying the evidence; accepting a new baseline is a cp or an rsync you write yourself.