Command Line Interface (CLI)
Introduction
blunderDB ships a full command line interface (CLI) in the same executable as the graphical interface. The CLI is especially useful for:
bulk import of matches: import an entire directory of match files (XG, SGF, MAT, BGF…) in a single command,
automation: integrate blunderDB into shell scripts for regular backups, scheduled exports, or processing pipelines,
server usage: manage databases on machines without a graphical environment,
quick inspection: check the contents or integrity of a database without launching the graphical interface.
The CLI shares exactly the same database format as the graphical interface: both write the same file, there is nothing to synchronise.
Note
If the application is open while a script writes. The file is in WAL mode: a read never blocks a write, and the two programs work on the same database without getting in each other’s way. Two writes, however, queue up — the second waits for the write lock (ten seconds per statement, plus a few retries) and only fails once the wait is exhausted, with a message naming SQLite:
Error: failed to import match: sqlite: save match: database is locked (5) (SQLITE_BUSY)
The graphical interface does not watch the file: it keeps displaying what it had loaded until CTRL-R reloads the positions. Nothing is lost, but the screen lags behind the database.
General syntax
The mode is detected automatically: if the first argument is a CLI command, blunderDB launches in headless mode, otherwise it launches the graphical interface.
# GUI
./blunderdb
# CLI
./blunderdb <command> [options]
The examples on this page write ./blunderdb: the binary as downloaded, called from the folder it sits in. Installed by a package, or linked from a folder on the PATH (see Download and Installation), it is simply called blunderdb.
Boolean options announced “default: yes” are turned off with the --option=false form — --recursive=false, --analysis=false. The space-separated form does not exist: --recursive false leaves the option at its default value and treats false as a stray argument.
Available commands
Command |
Description |
|---|---|
create |
Create a new database. |
import |
Import data (match, position, batch). |
export |
Export data. |
identity |
Show or move the issuer identity (the watermark signing key). |
open |
Turns a password-protected file (.dbx) into an ordinary database. |
search |
Search positions with filters. |
list |
Display database contents. |
match |
Display match positions and analysis. |
collection |
Manage collections (list, contents, creation, renaming, deletion, export). |
anki |
Spaced-repetition decks (list, statistics, forecast, synchronisation). |
rollout |
Plays a position out to the end to separate its moves or its cube decision (XGID or OGID). |
epc |
Computes the Effective Pip Count and the cube verdict of a bearoff position (XGID or OGID). |
bearoff |
Builds, lists, verifies and deletes bearoff databases. |
analyze |
Writes a gammonNet analysis for every position that has none. |
info |
Display database metadata. |
edit |
Edit the database metadata and thresholds. |
verify |
Verify database integrity. |
vacuum |
Compacts the database file, reclaiming freed space. |
repair |
Recomputes what the database derives from what it stores. |
delete |
Delete data. |
healthcheck |
Asks a running |
mcp |
Offers the database tools to an AI assistant (Model Context Protocol). |
completion |
Print a shell completion script (bash, zsh, fish). |
help |
Show help. |
version |
Show version. |
serve, migrate, call |
Server mode and migration to PostgreSQL: see Headless mode (server). |
Each command accepts the --help option to display its detailed help.
create — Create a database
Create a new database file with optional metadata.
./blunderdb create --db <path> [--user <name>] [--description <text>] [--force]
Options:
--db— Path to the database file to create (required).--user— Database owner name.--description— Database description.--force— Overwrite the file if it already exists.--format— Output format:text(default) orjson(path, version, user, description, creation date).
The .db extension is added automatically if missing. Parent directories are created as needed.
Example:
./blunderdb create --db mes_matchs.db --user "Jean" --description "Matchs de tournoi 2025"
import — Import data
Import match or position files into the database.
./blunderdb import --db <path> --type <type> [options]
Options:
--db— Path to the database (required).--type— Import type:match,positionorbatch(required).--file— File to import (formatchandposition).--dir— Directory to import (forbatch).--recursive— Recursively scan subdirectories (default: yes).--watch— With--type batch: does not stop, and imports each match file as it appears in--dir(Ctrl-C to stop).--watch-every— How often--watchlooks (default: 10s, floor 2s).--format— Output format:text(default) orjson.--fail-on-error— Fails if at least one item (positionorbatch) could not be imported, even when others succeeded.
The exit code follows four rules:
nothing was recognised — every file failed — : error, whether
--fail-on-erroris passed or not;only duplicates — every file was already in the database — : success. A directory re-run with no new file, a script’s ordinary night, exits with 0 and only
duplicatesnon-zero;partial failure (some items imported, others rejected): error only if
--fail-on-erroris passed;at least one new item imported, without
--fail-on-error: success, with the rejected files listed in the table.
Watching a folder
--watch turns the directory import into a watch: the command does not return, and imports each match file that appears in the folder. It is the headless form of the application’s watched folder.
# Importer ce que le dossier contient déjà, puis surveiller ce qui arrive
./blunderdb import --db base.db --type batch --dir ~/XG/Matches
./blunderdb import --db base.db --type batch --dir ~/XG/Matches --watch
Only files that appear are imported: whatever the folder holds at start-up is recorded as known and left alone — pointing a watch at four years of matches must not import all of them. The two commands above therefore compose exactly as one would hope.
A file is imported only once its size has settled, that is, seen twice unchanged: a match another program is writing grows from one look to the next, and importing it half-written would give a syntax error nobody can act on. The folder is not walked recursively. A network share that has become unreadable does not stop the watch, and its contents do not pass for new when it comes back.
Ctrl-C stops between files, never inside one: the file being imported finishes and its report is printed before the command returns.
Import a match
Supported formats: eXtreme Gammon (.xg, .xgp), GNUbg (.sgf), Jellyfish (.mat, .txt), BGBlitz (.bgf) and HedgeHog (.ogxm).
./blunderdb import --db base.db --type match --file match.xg
# Successfully imported match (ID: 1)
#
# Match Details:
# Players: Kévin Unger vs Maxence Job
# Event: HSBT Paris 2023
# Match Length: 7
# Games: 7
--format json delivers the same fields in a single document:
{
"type": "match",
"match_id": 1,
"player1": "Kévin Unger",
"player2": "Maxence Job",
"event": "HSBT Paris 2023",
"location": "Paris, Fédération Française de Bridge",
"match_length": 7,
"games": 7
}
Import positions
Imports positions from a text file, one JSON position per line. This is exactly what export --type positions writes: the two commands answer each other, an export re-imports as is, with nothing to touch up.
./blunderdb import --db base.db --type position --file positions.txt
# Successfully imported 4 positions
A line, as export produces it — the board takes up most of it, twenty-six points followed by the checkers off:
{"id":1,"board":{"points":[{"checkers":0,"color":0},{"checkers":1,"color":1},…],"bearoff":[0,0]},"cube":{"owner":-1,"value":0},"dice":[0,0],"score":[7,7],"player_on_roll":0,"decision_type":1,"has_jacoby":0,"has_beaver":0,"individually_imported":true,"flagged":false}
The analysis and the comments do not travel through this format: it carries the position, nothing else. To move an entire library, export --type database is the one to use.
Batch import
Import all match files from a directory in a single operation. This is the most efficient method for importing a large number of matches.
./blunderdb import --db base.db --type batch --dir ./matchs/
./blunderdb import --db base.db --type batch --dir ./matchs/ --recursive=false
./blunderdb import --db base.db --type batch --dir ./matchs/ --format json --fail-on-error
A summary table shows for each file whether the import succeeded (✓), failed (✗) or was a duplicate (⊘). A duplicate is not counted as a failure, and a batch containing only duplicates is a success (see the rules above).
Batch importing from: ./matchs/ (recursive: true)
Found 3 match file(s) to import
[1/3] Importing: 02_NDT_FR.txt... ERROR: failed to parse file: ingest: parse gnubg file: invalid MAT file: no match header found
[2/3] Importing: test.mat... DUPLICATE
[3/3] Importing: test.xg... OK (ID: 1, 341 positions)
====================================================================
IMPORT SUMMARY
====================================================================
Status File ID Player 1 Player 2 Games Positions Error
------ ---- -- -------- -------- ----- --------- -----
✗ 02_NDT_FR.txt 0 0 failed to parse file: ingest: ...
⊘ test.mat 0 0
✓ test.xg 1 Kévin Unger Maxence Job 7 341
--------------------------------------------------------------------
Total: 3 files | Success: 1 | Duplicates: 1 | Failed: 1 | Positions imported: 341
--format json gives the same thing in a script-friendly form: one object per file in files, then the totals. A quiet night leaves duplicates alone non-zero and failed at zero, with the exit code at 0; only a batch where nothing was recognised exits in error.
{
"files": [
{"file_path": "02_NDT_FR.txt", "success": false, "error": "failed to parse file: …"},
{"file_path": "test.xg", "success": true, "positions": 341}
],
"total": 3,
"success": 1,
"duplicates": 1,
"failed": 1,
"positions_imported": 341
}
export — Export data
Export database contents to files.
./blunderdb export --db <path> --type <type> --file <output> [options]
Options:
--db— Source database (required).--type— Export type:database,positions,matchesormat(export of one or more matches as a Jellyfish.mattranscript) (required).--file— Output file (required, except for--type matused with--dir).--dir— Output directory for batch.matexport (several matches, one file per match; without--match-ids, all matches are exported).--analysis— Include analysis (default: yes).--comments— Include comments (default: yes).--filters— Include filter library (default: yes).--played-moves— Include played moves (default: yes).--matches— Include matches (default: yes).--collections— Include collections (default: no).--collection-ids— Collection IDs to export (comma-separated).--match-ids— Match IDs to export (comma-separated, empty = all).--tournament-ids— Tournament IDs to export (comma-separated).--password— Wraps the result in an encrypted container (.dbx).--watermark— Writes a signed statement of origin into the exported file (see Handing out a database: origin and password).--watermark-note— Free text attached to the watermark (terms of use, contact); used together with--watermark.--format— Output format:text(default) orjson(a document summarising the export: path, size in bytes, counts).
Examples:
./blunderdb export --db base.db --type database --file sauvegarde.db
./blunderdb export --db base.db --type positions --file positions.txt
./blunderdb export --db base.db --type matches --file selection.db --match-ids 1,3,5
# .mat : un match, puis plusieurs (ou tous) dans un répertoire
./blunderdb export --db base.db --type mat --match-ids 5 --file match5.mat
./blunderdb export --db base.db --type mat --match-ids 5,9,12 --dir sorties/
./blunderdb export --db base.db --type mat --dir sorties/
# .dbx : filigrané et protégé par mot de passe
./blunderdb export --db cours.db --type database --file cours-diffusion.dbx \
--watermark "Cours de Jean Dupont — 12 mars 2026" \
--watermark-note "Merci de ne pas rediffuser." \
--password secret
A watermark is signed with the local issuer identity (see the identity command below): it cannot be forged, but it can be removed — the file remains an ordinary SQLite database. It protects nothing; it only states where the file came from. A password protects the file’s transport (the stray copy, the attachment sent by mistake), not the database itself: whoever has the password can open it. blunderDB never records anything on the recipient’s side (no registry, no log) — see ADR-0007.
identity — Issuer identity
Shows or moves your issuer identity: the Ed25519 key that signs every watermark. It is created on its own the first time a watermark is applied; there is nothing to configure. It belongs to a person, not to a database: everything you mark carries a single public fingerprint.
./blunderdb identity
./blunderdb identity --name "Jean Dupont"
./blunderdb identity --export jean.bdbid --passphrase pw
./blunderdb identity --import jean.bdbid --passphrase pw
Options:
--name— Changes the identity’s display name.--export— Exports the identity to a.bdbidfile.--import— Imports an identity from a.bdbidfile.--passphrase— Optional passphrase protecting the exported/imported file (the local identity itself is deliberately left unprotected).--format— Output format:text(default) orjson(name, fingerprint, storage path).
The exported file lets anyone holding it sign in your name — do not share it. Renaming changes a label and nothing else: files already marked keep the name they were sealed under, and still verify.
open — Opening a protected file
Turns a password-protected file (.dbx) into an ordinary database. The password is asked once; after that it is a normal file.
./blunderdb open --db cours.dbx --password secret
./blunderdb open --db cours.dbx --password secret --file ./mon-cours.db
Options:
--db— The.dbxfile to open (required).--password— The container’s password (required).--file— Output path for the ordinary database (default: same name,.dbextension).
What the password protects: the file’s transport — the copy left in a downloads folder, the attachment sent by mistake. Not the database: whoever has the password can open it. The container’s header is in the clear, so blunderdb info reads the origin of a protected file without its password.
search — Search positions
Search positions in the database using combinable criteria.
./blunderdb search --db <path> [options]
Main options:
--db— Database (required).--format— Output format:table,jsonorxgid(default:table).--limit— Maximum number of results (0 = unlimited).--offset— Skip the first n results before starting to count; together with--limit, this is pagination.--export— Export results to a new database.--query-help— Shows the list of tokens--queryunderstands, and stops there. No database is opened:--dbis unnecessary.
Available filters:
--decision— Decision type:checkerorcube.--dice— Dice roll.5,3matches positions where both dice match (any order).5matches positions where a 5 appears on either die (the second die value is ignored). Implies--decision checkerwhen no--decisionvalue is given.--pip-min/--pip-max— Pip count difference range.--winrate-min/--winrate-max— Win rate range (%).--cube— Cube value.--score1/--score2— Player scores.--match-length— Match length.--error-min— Threshold on what a mistake in the position costs: the gap between the best move and the second-best one, or the largest of the three cube errors. In equity points —--error-min 0.1keeps positions where a mistake costs at least a tenth of a point. It says nothing about what was actually played there.--move-error-min/--move-error-max— Threshold on the error of the move actually played by player 1. In thousandths of equity (millipoints):--move-error-min 50, i.e. a twentieth of a point. This is theEtoken of the search grammar, written as is.--has-analysis— Only positions with analysis.--off1-min/--off2-min— Minimum checkers off (player 1/2).--match-ids— Filter by match IDs (comma-separated).--tournament-ids— Filter by tournament IDs (comma-separated).--position-ids— Filter by position IDs: range2,7(positions 2 to 7) or explicit semicolon-separated list5;10;15.--individual— Only positions imported on their own, that is, the ones you added yourself and not the ones a match import brought in.--flagged— Only the positions flagged for study in the originating software (eXtreme Gammon flags). Not retroactive: matches already imported must be imported again to yield their flags.--has-comment— Only the positions carrying a comment. The origin makes no difference: a note typed by hand and a comment brought in by a match import both count. Match or tournament comments are not consulted.--no-comment— Only the positions without a comment. Mutually exclusive with--has-comment.
Warning
--error-min and --move-error-min do not measure the same thing and do not take the same unit: the factor is a thousand. The first is given in equity points (0.1), the other two in thousandths (100) — one point is worth 1000 thousandths. It is --move-error-min that answers “where did I go wrong”; --error-min answers “which positions were tricky”.
What search prints:
--format table (the default) gives one line per position: the identifier, the score, the cube value, the decision type, the roll, the best decision and its equity. The last two columns stay empty for a position without analysis.
Found 5 position(s)
ID Score Cube Type Dice Best Move Equity
-- ----- ---- ---- ---- --------- ------
2 7-7 0 cube No Double -0.005
4 7-7 0 cube No Double -0.027
6 7-7 0 cube No Double -0.161
8 7-7 0 cube No Double 0.256
10 7-7 0 cube No Double -0.234
--format json gives an array of the same positions. The fields id, score, cube, decision_type (checker or cube) and dice are always present; best_move, equity and xgid appear only when the position carries an analysis that fills them in. The Found n position(s) line is still printed before the array: a script expecting only JSON must skip the first line, or go through --export.
[
{
"id": 5266,
"score": [
5,
4
],
"cube": 1,
"decision_type": "checker",
"dice": [
4,
3
],
"best_move": "10/3",
"equity": 0.565
}
]
--format xgid prints one XGID per line, and nothing else. It only prints the positions whose saved analysis carries an XGID: a position pasted into the application from a text export, or a BGF file that carries one. Positions brought in by importing an XG, GNUbg or Jellyfish match do not carry one, and the output is then empty. The collection show subcommand, for its part, regenerates the XGID from the board.
The query language:
The flags above cover only some of the filters. --query gives access to the application’s query language — the one used by the command bar — and therefore to every filter that is not drawn on the board: move pattern, comment text, player, date, equity, excluded dice, zones and blots.
The grammar is written in only one place, Search Filters. Its table gives every token, its form, and the search flag it corresponds to when one exists. This page does not repeat it.
./blunderdb search --db base.db --query 's p>30 E>50'
./blunderdb search --db base.db --query 's m"13/11" t"blunder" pl"Alice" T>2026/01/01'
Ranking a position’s neighbours goes through the same grammar: --query 's like42', alone or followed by other tokens to narrow the ranked set.
--query-help recalls the list without opening a database:
$ ./blunderdb search --query-help
blunderdb search --query — the interface's query language
A query is the same text the application's command bar takes:
s cube p>30 E>50 cube decisions, 30+ pips behind, 50+ millipoints of error
s m"13/11" T>2026/01/01 played 13/11, imported this year
Flags (no value):
cube score match the cube / the score of the position on the board
d match the decision type (checker or cube)
…
Ranges — each takes x>n, x<n or xa,b (lower-case: you; upper-case: the opponent):
p P pip count difference / absolute pip count
…
E error of the played move, in millipoints
T creation date, T>2026/01/01
Values:
t"tag" comment text (";" separates alternatives)
…
--query replaces the filter flags rather than adding to them: combining them is refused, naming the offending flag. The flags that say where to search and how to display — --db, --format, --limit, --offset, --export — remain valid.
A token nothing recognises fails the command rather than narrowing the search in silence. Two limits follow from having no board on the command line: the checker pattern cannot be typed, and the five tokens that read the board — cube, score, d, D/D1 and x — compare here against an empty board. A search that needs one of them is written entirely in flags, since --query does not combine with them — for example, cube decisions at least 30 pips behind and wrong by at least 50 millipoints:
./blunderdb search --db base.db --decision cube --pip-min 30 --move-error-min 50
Examples:
./blunderdb search --db base.db --decision cube
./blunderdb search --db base.db --individual
./blunderdb search --db base.db --error-min 0.1
./blunderdb search --db base.db --tournament-ids 1 --export cubes.db
# 6-5 dans les deux ordres, puis un 6 sur l'un des deux dés
./blunderdb search --db base.db --dice 6,5
./blunderdb search --db base.db --dice 6
# Pagination
./blunderdb search --db base.db --format json --limit 10 --offset 20
list — List contents
Display database contents.
./blunderdb list --db <path> --type <type> [--limit <n>]
Types:
matches— List of imported matches.tournaments— List of tournaments.positions— Lists the positions (10 by default). With--format csvit becomes a tabular export: one row per position, with its XGID, phase, score, cube, pips and the derived analysis columns.imports— The recorded imports, most recent first: id, date, format, source, matches imported / skipped / enriched, unreadable files and new positions. With--batch <id>, shows the full report of one import: flagged positions, positions with no analysis, the PR over that batch and its five worst decisions (see The import report).stats— Performance statistics report: PR / Snowie ER / MWC (overall, checker, cube), rolling PR over the last N decisions, top blunders, breakdown by cube action and error magnitude histogram.players— Comparison table, one row per player in the database: matches, wins/losses, counted decisions, overall / checker / cube PR, Snowie ER, errors, blunders and luck. This is the command-line counterpart of the Stats panel’s Players tab.moves— Tabular export of the recorded moves, one per row, with the match they belong to repeated on every row: identifiers, date, players, length, move number and type, position, dice, played move, cube action, luck.--format csvrequired.analyses— Tabular export of the stored analyses, one per row: engine, depth, best move and its equity, played move’s error, best cube action and its error, the six win rates.--format csvrequired.tags— The database’s tag vocabulary: every#wordwritten in a comment, with the number of positions carrying it, most used first. On a database with no tag at all, shows the recommended vocabulary rather than an empty list (see Tags). Accepts--format jsonand--format csv.
Tabular exports
Three types — positions, moves and analyses — export to CSV for a notebook, a spreadsheet or a script:
./blunderdb list --db base.db --type positions --format csv > positions.csv
./blunderdb list --db base.db --type moves --format csv > moves.csv
./blunderdb list --db base.db --type analyses --format csv > analyses.csv
--limit applies only if you pass it. Its default (10) exists so that a list printed to a terminal does not scroll the whole database past you; an export goes to a file read by a program, and silently truncating it at ten rows would be a trap nobody notices until the figures are wrong.
The columns are a contract. A notebook or a script written against these names must keep working: columns are added at the end, never renamed and never reordered. Every equity is in integer millipoints, because that is how they are stored and because a float in a CSV invites a locale to reformat it.
Parquet is not offered, and that is measured rather than dogmatic: a columnar library weighs several megabytes in a binary whose size is a tracked concern, while everything this export is for reads CSV in one line (pd.read_csv, polars.read_csv, read.csv, a spreadsheet). Parquet earns its keep on tens of millions of rows; a ten-year-old backgammon library holds a hundred thousand. If one day the difference is measurable on a real database, that measurement is what should reopen this.
An example Jupyter notebook comes with these exports (notebooks/blunderdb-analyse.ipynb in the repository): PR over time, the distribution of error magnitudes, the ten worst decisions with their XGID. It uses nothing but those three CSV files, and it is executed every night in continuous integration — a notebook nobody runs is a notebook that has stopped working without anybody noticing.
Options (stats type only):
--metric— Displayed metric:prormwc(default:pr).--player— Restrict to the given player.--tournament— Restrict to one or more tournament IDs (comma-separated).--from— Start date (YYYY-MM-DD).--to— End date (YYYY-MM-DD).--decision-type— Decision type:all,checkerorcube(default:all).--top-blunders— Number of worst errors listed (default: 10).--format— Output format:textorjson(default:text).
Options (imports type only):
--batch— A batch id: shows its full report instead of the list.--queue— With--batch: the batch’s study queue rather than its report — the positions worth a second look, in the order to walk them (see The study queue). The decisions that cost something first, then the positions flagged in the source tool, then the close cube decisions; a position appears only once.--format— Output format:textorjson(default:text).
The measured half of the report is recomputed on every call: a batch whose positions have since been analysed returns today’s figures, not those of the day it was imported.
Options (players type only):
--from/--to— Date bounds (YYYY-MM-DD), for instance the days of a competition.--tournament— Restrict to one or more tournament IDs.--format— Output format:text,jsonorcsv(default:text).
--player and --decision-type do not apply to this type: the table covers every player and already splits checker and cube into separate columns.
Note
A dash “—” (an empty field in CSV) marks a value that was never measured, not to be confused with zero. That is the case for luck on any match imported before schema version 2.15.0, as well as for the formats that do not carry it (BGF, Jellyfish .mat): re-import the source files to obtain it. The luck_rolls column says how many rolls the average covers.
Each type prints one block per item, preceded by the total found. The final line reminds you that the list is truncated by --limit, whose default value is 10 for positions:
Found 3859 position(s):
ID: 1
Score: 7-7
Player on roll: 0
Decision: Checker play
ID: 2
Score: 7-7
Player on roll: 0
Decision: Cube action
…
(Showing 10 of 3859 positions, use --limit to see more)
Examples:
# Les imports enregistrés, puis le compte rendu de l'un d'eux
./blunderdb list --db base.db --type imports
./blunderdb list --db base.db --type imports --batch 3
./blunderdb list --db base.db --type stats
./blunderdb list --db base.db --type stats --metric mwc --player "Alice"
./blunderdb list --db base.db --type stats --decision-type checker --from 2026-01-01
./blunderdb list --db base.db --type stats --format json
# Un tableau par joueur, borné aux dates d'une compétition
./blunderdb list --db base.db --type players --from 2026-03-01 --to 2026-03-08
./blunderdb list --db base.db --type players --format csv
./blunderdb list --db base.db --type matches
./blunderdb list --db base.db --type positions --limit 20
match — Display a match
Display positions and analysis of an imported match.
./blunderdb match --db <path> --id <id> [--format <format>] [--output <file>]
Options:
--db— Database (required).--id— Match ID to display (required).--format— Output format:json,textorsummary(default:json).--output— Output file (default: stdout).
Examples:
./blunderdb match --db base.db --id 1 --format summary
./blunderdb match --db base.db --id 1 --format text
./blunderdb match --db base.db --id 1 --output match1.json
collection — Manage collections
Manages collections, those sets of positions picked by hand in the GUI’s Collections panel. Each subcommand takes --db; list and show accept --format text (default), json or csv, like list.
./blunderdb collection <subcommand> [options]
Subcommands:
list— List of collections: id, name, number of positions, description.show --id <id>— Positions of a collection: id, index (the 1-based number shown in the GUI’s status bar), score, decision type and XGID.create --name <name> [--description <text>]— Creates an empty collection.filter --id <id> --query <query>— Makes a collection living: its content becomes the result of a search, re-evaluated every time it is opened. The query is written in the application’s own search grammar (see Search Filters).--clearturns it back into a hand-made list, keeping the positions it already held.rename --id <id> --name <name> [--description <text>]— Renames a collection (the description is kept if not given).delete --id <id> [--confirm]— Deletes a collection; its positions remain in the database.export --id <id[,id…]> --out <file.db> [--analysis=false] [--comments=false] [--watermark <text>] [--watermark-note <text>]— Exports one or more collections to a new database file, through the same call as the GUI’s export window (see theexportcommand for the watermark).
The XGID shown by show is the one saved with the position’s analysis when it exists (BGF and XGP imports); otherwise it is generated from the board exactly as Copy position does in the GUI — the match length is then the larger of the two remaining scores, since a saved position does not retain the real one.
Examples:
./blunderdb collection list --db base.db
# Found 2 collection(s):
#
# ID Name Positions Description
# -- ---- --------- -----------
# 1 Ouvertures blitz 0 À revoir
# 2 Videaux ratés 0
A database with no collection answers No collections found in database and still exits with code 0.
./blunderdb collection show --db base.db --id 3 --format csv
./blunderdb collection create --db base.db --name "Ouvertures blitz"
./blunderdb collection rename --db base.db --id 3 --name "Ouvertures"
./blunderdb collection delete --db base.db --id 3 --confirm
# Exporter deux collections, marquées de leur origine
./blunderdb collection export --db base.db --id 3,4 --out ouvertures.db \
--watermark "Cours de Jean Dupont - 12 mars 2026"
anki — Spaced-repetition decks
Consults and maintains the spaced-repetition (FSRS) decks of the GUI’s Anki panel. Reviewing a card needs the board and stays in the GUI; the CLI lists, measures and resynchronises.
./blunderdb anki <subcommand> [options]
Subcommands:
decks [--format text|json|csv]— List of decks: source, number of cards, due cards, new cards.stats --deck <id> [--format text|json]— Review statistics for a deck: total, new, learning, review, due now, and its FSRS parameters.forecast [--deck <id>] [--days <n>] [--format text|json|csv]— Cards coming due per calendar day (UTC) over the nextndays (default 30, maximum 365); day 0 absorbs all overdue cards;--deck 0(default) covers all decks.sync --deck <id>— Adds a card for every position of the deck’s source that does not have one yet; existing cards keep their schedule.retention --deck <id> [--format text|json]— a deck’s measured retention, against the target its owner chose.card --id <id> --action suspend|unsuspend|bury|remove [--format text|json]— acts on one card. Suspending sets it aside without losing its history (it no longer comes up in a session); burying hides it until the next day, saying nothing about its worth; removing deletes it from the deck — the position itself stays in the library, a deck being only a study list laid over it.log [--deck <id>] [--limit <n>] [--format text|json]— the review log, most recent first (--deck 0, the default, covers every deck;--limitdefaults to 20). The log is what the scheduler was actually told, as opposed to what it plans today: the only place a grade entered by mistake can be seen.
A deck based on a collection re-reads its collection. A deck based on a search keeps the search as the GUI saved it (command, board and the identifiers of the positions found at that time): the search grammar lives in the GUI, so the CLI resynchronises from the saved identifiers and reports it on the error output — open the deck in the GUI to replay the search itself.
Examples:
./blunderdb anki decks --db base.db
./blunderdb anki stats --db base.db --deck 2 --format json
./blunderdb anki forecast --db base.db --deck 2 --days 14
./blunderdb anki sync --db base.db --deck 2
./blunderdb anki card --db base.db --id 12 --action suspend
./blunderdb anki log --db base.db --deck 2 --limit 50
# Day Due
# --- ---
# 2026-09-02 12
# 2026-09-03 4
# ...
#
# 37 card(s) due over 14 day(s)
stats — Recurring errors
Groups the errors of a filter by game plan and by theme, the most costly first: the Recurring errors table of the Errors tab of the Stats panel (see Stats Panel). Global statistics remain under list --type stats.
./blunderdb stats recurring --db <fichier> [options]
Options:
--player <nom>— Only the decisions of this player.--tournament <ids>,--from <AAAA-MM-JJ>,--to <AAAA-MM-JJ>,--decision-type all|checker|cube— The same filter aslist --type stats.--limit <n>— Number of groups shown in text (default 20,0for all).--format text|json— The JSON carries each group with the full list of its positions.
A checker play theme is gammon, blots, point or passive; a cube theme is offer_missed, offer_premature, answer_wrong_pass or answer_wrong_take. Errors that no rule names leave the ranking: they are listed apart, one line per game plan (Unthemed field in JSON), because the explanation only speaks from 60 mp, above the Error threshold. The COST (PR) column is the share of the filter’s PR that the group represents.
Examples:
./blunderdb stats recurring --db base.db --player "Alice"
./blunderdb stats recurring --db base.db --decision-type checker --format json
cubematrix — Cube matrix
Gives a position’s cube verdict at every score of a match: for each away × away cell, whether the position is a double and whether it is a take. Pure computation: no database is opened, the position arrives as an XGID or an OGID (OpenGammon).
./blunderdb cubematrix [options] '<XGID|OGID>'
Options:
--format— Output format:textorjson(default:text).--match-length— Match length the grid spans, from 1 to 25 (default: 7).--ply— Search depth for every cell,0or2(default: 2).--prune-k— Number of candidate moves kept by the prune network (default: 12).--jobs— Searches run in parallel (default: one per core). The grid is identical whatever the value; only the time changes.
The position’s own score is ignored — the grid replaces it — but its cube is kept: the question asked is “at what score would I turn this cube”. The grid is post-Crawford throughout.
Every cell is its own search, because the engine is match-aware: a single search read through different match equities would be wrong exactly where the score matters.
Examples:
# Grille d'un match en 5 points
./blunderdb cubematrix --match-length 5 'XGID=-b----E-C---eE---c-e----B-:0:0:1:00:0:0:0:7:10'
# Les équités de chaque case, pour un script
./blunderdb cubematrix --format json '<XGID>'
text output: a grid whose rows are the points still needed by the player on roll and whose columns are the opponent’s, then the legend of the ND / DT / DP / TG tokens and the reason for each refused cell.
rollout — Rollout of a position
Plays a position a large number of times with gammonNet, to settle what a search cannot: two moves a few thousandths apart, or a cube decision where the model hesitates. With dice, it is its moves that are played (the best ones at the rollout depth, at least 2 plies, or those named by --move); without dice, its cube decision (No double and Double/Take; Double/Pass is worth exactly +1). The position comes from an XGID or an OGID, or from a database (--db and --id); without --store, nothing is recorded.
./blunderdb rollout [options] '<XGID|OGID>'
./blunderdb rollout --db <path> --id <position> [--store] [options]
Options:
--preset— Starting setting:fast(default: 216 games truncated at 7 plies, stop at JSD 3 after 108) orstandard(1296 games truncated at 11, stop at JSD 3 after 324). Both play at 0 ply — the network alone, for moves, cube and leaves;--ply 1or more plays deeper, for a time several times longer. The following options override it one by one.--games,--min-games,--truncation,--jsd,--ply,--candidates— The rollout parameters (--truncation 0plays each game to the end,--jsd 0never stops before the end).--move— A move to play, in blunderDB notation (repeatable).--seed— Dice seed, fixed by default: the same command gives the same numbers.--jobs— Games played in parallel (default: one per core); only the running time changes.--format— Output format:textorjson(default:text).--db,--id— The database and the identifier of the position to play, instead of an XGID.--store— Records the finished rollout on the position, as a second analysis carrying its own settings, beside the imported or evaluated analysis, which it never replaces. An interrupted rollout is not recorded; of two rollouts with the same settings, the longer series is kept, and a rollout with other settings is added alongside.--list— Prints the rollouts stored on the position, newest first, instead of rolling one out.
All candidates play the same dice, the luck of each roll is removed from the result of each game (variance reduction), the first two rolls are stratified, and a game stops where the two-sided bearoff database covers it. Each line gives the equity, its 95 % interval, the number of games played and the JSD, the gap to the best in standard deviations of the difference. The cube is played during the games: the ranking is more reliable than the absolute equity. Ctrl-C displays what the completed games have established.
Examples:
./blunderdb rollout 'XGID=-b----E-C---eE---c-e----B-:0:0:1:31:0:0:0:0:10'
./blunderdb rollout --move '8/5 6/5' --move '24/23 13/10' '<XGID>'
./blunderdb rollout --db base.db --id 42 --preset standard --store
./blunderdb rollout --db base.db --id 42 --list
epc — EPC calculator
Computes the Effective Pip Count, the winning probability and the money cube verdict of a bearoff position given as an XGID or an OGID (OpenGammon). Pure computation: no database file is involved.
./blunderdb epc [options] '<XGID|OGID>'
Options:
--format— Output format:textorjson(default:text).--bearoff-ts— Optional two-sided bearoff database (.bd) widening the built-in TS-06-06 (also read from theBLUNDERDB_TS_PATHenvironment variable). The widest valid database wins; an invalid file is ignored with a warning.
Regimes. Within the range the two-sided database covers, the winning probability and the money cube analysis (cubeless, ND, D/T, D/P, verdict) are exact. Outside it, the winning probability is estimated (convolution of the one-sided roll distributions plus a calibrated correction) and shown with its measured error margin; the cube verdict is deliberately never estimated (see ADR-0009).
Examples:
# Régime exact : six pions ou moins de chaque côté
./blunderdb epc 'XGID=-BBB------------------bbb-:0:0:1:00:0:0:0:0:10'
# Avec la table TS-06-11 calculée : exact jusqu'à onze pions par joueur
./blunderdb epc --bearoff-ts ~/.local/share/blunderdb/gnubg_ts6x11.bd 'XGID=…'
bearoff — Bearoff databases
Makes and manages the bearoff databases. Nothing is downloaded and nothing is embedded: a table is computed here and checked against the fingerprint gnubg produces for its domain. No sub-command talks to a database — a bearoff table is arithmetic about the game, not about anyone’s positions — so none of them takes --db.
./blunderdb bearoff generate --ts <domain> [options]
./blunderdb bearoff list [options]
./blunderdb bearoff verify <file.bd> [options]
./blunderdb bearoff delete --ts <domain> [options]
The domain is written as under makebearoff: 6x9 for the two-sided table at nine chequers per player, os8 for the eight-point one-sided table (os alone means os6).
The two families do not answer the same question. A two-sided table widens the domain where the winning probability and the cube verdict are exact; a one-sided table widens how far from home a chequer may stand without the EPC going silent (up to ten points).
generate. States the size, the memory and the estimated time before starting, then shows the percentage and the measured remaining time.
--ts— Two-sided domain to compute, for example6x9.--os— One-sided domain to compute, as a point count: 6 to 12. Exactly one of the two is required.--cores— Cores to use (default: every core but one).--data-dir— Where to write it (default: the application’s data directory).--quiet— No progress line.
CTRL-C pauses. The signal is caught: the state is written beside the table and the same command run again continues where it stopped rather than recomputing everything. Half an hour of arithmetic is worth writing down. bearoff delete throws a pending resume away. Only the two-sided sweep pauses; the one-sided one is sequential and --cores buys it nothing.
list. Prices every domain — size, memory, time on this machine — and says which are already present, with their verdict, and which have a paused run. --format json for a script, --cores to change the estimate’s assumption.
verify. Answers verified (the same bytes as the reference), unverified (well formed, but no fingerprint is recorded for that domain) or corrupt (the file contradicts itself). Exits with an error in the last case: this command is made to be put in a script.
delete. Removes the table, the pending resume and the debris of a dead run. A default domain is recomputed at the application’s next launch; a wider one is not.
Examples:
# Ce que cette machine a, et ce que chaque domaine coûterait
./blunderdb bearoff list
./blunderdb bearoff generate --ts 6x9 --cores 4
# OS-08 : l'EPC répond alors jusqu'à un pion sur la 8
./blunderdb bearoff generate --os 8
# Sur un serveur, dans le volume que lit le démon
./blunderdb bearoff generate --ts 6x11 --data-dir /srv/bearoff
./blunderdb bearoff verify /srv/bearoff/gnubg_ts6x11.bd
analyze — gammonNet catch-up
Writes a gammonNet analysis for every position that has none — the catch-up of a library built before this feature existed (ADR-0013, ADR-0015). It is the same operation as the automatic run after an import and the “Analyze now” button of the graphical interface, and as the /v1/gammonnet.analyzeMissing endpoint of the serve daemon for a tenant — three different forms of the same operation, not three separate logics (see Headless mode (server)).
./blunderdb analyze --db <path> [options]
Options:
--db— Database (required).--ply— Search depth (default: 2, the canonical setting).--prune-k— Pruning width (default: 12, the canonical setting).--candidates— Number of candidate moves kept per checker-play decision (default: 10).--jobs— Number of positions analyzed in parallel (default: the number of cores of the machine).--match— Restrict the sweep to the positions of a single match (0, the default, means the whole library).--compare— Writes nothing: compares gammonNet with the imported analyses instead of filling gaps (see below).--limit— With--compare, stops after this many positions (0 = all).--format— Output format:text(default, with progress) orjson(a single summary document, printed at the end).--rollout— Rolls out the positions chosen by--queryinstead of filling gaps (see below).--query— With--rollout, the positions to play, in the search query language (search --query-help); empty, all of them.
A match imported without an analysis now gets a Performance Rating. That is the case of a match played online, or of a Jellyfish .mat file, that nobody ran through XG. blunderDB knew its positions and the moves played, but nothing said what they were worth; once the batch has run, the move actually played is compared with gammonNet’s ranking and the gap feeds the PR and every other indicator. The played move comes from the match’s own move table, written at import whether or not the file carried an analysis — it is never guessed.
A database analysed with a version older than this one does not need to be re-evaluated: repair recomputes the columns from what is already stored and gives those matches their PR back.
One match only (--match). With the id list --type matches prints, the sweep only walks the positions of that match: same gap rule, same guarantees, narrower scope. A match that has just been imported gets its analyses without the rest of the library being swept, and a match corrected then analysed a second time costs only the positions the correction created, since every other one already carries an analysis. The option combines with neither --stale nor --compare, which both look at positions that already have an analysis: asking for both is an error, rather than a silently ignored scope.
Parallelism (--jobs). The positions of a batch are independent — no search informs the next one — so they are spread over --jobs threads, each with its own evaluator. The analyses written are identical whatever the value of --jobs; only the computation time changes. --jobs 1 leaves the machine free for something else. Cancellation is not affected: Ctrl-C stops the batch before any new position, and everything already computed is written.
The hole rule (ADR-0013). A position that already carries an analysis — XG, GNUbg, BGBlitz, or a previous gammonNet run — is never touched, whichever engine is missing. Only a position with no analysis at all is written. The command can therefore be re-run at any time without risk, and interrupted cleanly: Ctrl-C cancels without losing anything already written, and the next run resumes exactly where the previous one stopped — no journal is needed, since “the positions without analysis” is recomputed on every launch.
Example:
./blunderdb analyze --db base.db
# Analyzing 1204 position(s) with gammonNet (2-ply, k=12, 16 job(s))...
# 1/1204 (0%)
# 61/1204 (5%)
# ...
# 1204/1204 (100%)
# Done.
./blunderdb analyze --db base.db --jobs 1
# Un seul match, celui qui vient d'être importé
./blunderdb analyze --db base.db --match 12
Batch rollouts (--rollout). Each position chosen by --query is played by a rollout, one after the other on all cores, and the rollout is recorded beside its analysis, never in its place. The value is a preset — fast (216 games truncated at 7) or standard (1296 games truncated at 11) — or free settings: an optional preset, then games=, min-games=, truncation=, jsd=, ply=, candidates=, seed=, separated by commas. A position that already carries a rollout with the same settings is skipped: a run interrupted by Ctrl-C resumes where it stopped, the position in progress being dropped entirely. A position analysed only by a rollout is found by the search through it; a position already analysed keeps the columns of its analysis.
./blunderdb analyze --db base.db --rollout fast --query 'E>80'
./blunderdb analyze --db base.db --rollout 'standard,ply=1' --query 'c'
--compare: what is gammonNet worth on your library?
The engine’s accuracy is measured elsewhere against reference corpora and against the exact bear-off table. Neither of those measurements answers the question a user actually has, which is about their positions: on the matches imported from XG, where does the embedded engine disagree with the analysis that came in the file, and what would that disagreement cost?
--compare answers, and writes nothing. That is not a precaution but the point of the command: ADR-0013 protects an imported analysis unconditionally, so the comparison can be run on a library one particularly does not want rewritten.
The report gives:
the agreement rate on the best answer, split between checker plays and cube decisions — the two have nothing to do with each other and a single rate would hide which one is slipping;
the cost of the disagreement, priced on the imported analysis’s own scale: what the move gammonNet prefers is worth according to the imported engine, minus what its own best move is worth. That direction is the only one both engines can price together; pricing a disagreement twice would invite reading the smaller of the two numbers;
the breakdown by game phase, which is what says where the disagreements sit;
the ten most expensive disagreements, position by position.
Two engines write the same move differently — XG writes “13/7” where gammonNet writes “13/8 8/7”, hits are marked on one side and not the other, repetition is sometimes collapsed into “(2)”. Those differences are dialect, not disagreement: the comparison reduces both notations to a canonical form before comparing them. Without that, a test corpus showed 78.8 % agreement instead of 93.2 % — fifteen points of false disagreements.
A move the imported engine did not list cannot be priced on its scale: it counts as a disagreement of zero cost rather than of an invented one.
# Comparer sur un échantillon de 500 positions
./blunderdb analyze --db base.db --compare --limit 500
# compared: 118 decision(s) (refused 2, failed 0)
# same best answer: 93.2% (110/118)
# checker play: 93.7% (59/63)
# cube decision: 92.7% (51/55)
# ...
transcribe — Replay a transcription
Replays a transcription and reports what the replay finds in it. The source is a .mat file, a match of the library or a transcription draft — exactly one of the three. A match is read through the .mat it would export, so what is replayed is what an export would contain.
./blunderdb transcribe --mat <fichier> [--check] [--render <sortie>]
./blunderdb transcribe --db <path> --match <id> --check
./blunderdb transcribe --db <path> --draft <id> --check
./blunderdb transcribe --db <path> --match <id> --edit [--accept-losses]
./blunderdb transcribe --db <path> --draft <id> --finish|--abandon
Options:
--mat—.matfile to replay.--db— Database, for--matchand--draft.--match— Id of the library match to replay.--draft— Id of the transcription draft to replay.--check— List the inconsistencies found (the default behaviour).--render— Write the transcription back as.matto this path.--format— Output format:text(default) orjson.--edit— Opens a draft on the--match(or returns the one already open on it).--accept-losses— With--editon an imported match: accepts that its analyses and comments may be lost.--finish— Finishes the--draft: writes its match, or replaces the one it was opened from, and releases the draft.--abandon— Abandons the--draft: deletes it without a match; a match it was opened from stays as it is.--yes— With--abandonon a draft that never produced a match: confirms that everything typed in it is lost.
--check names each inconsistency with the number of the action and the game it sits in: an illegal play, two turns in a row for the same player, an impossible cube action, an action past the end of the match, a play whose steps do not use its own dice, a game’s first play rolled as a double, which no opening roll can be, an unrecorded play — the ??? cell gnubg writes when it did not keep the play that was made, and which is not a dance, a declared score that does not add up — a game whose score line is not the one the previous games give, replayed at the written score.
An inconsistency is reported, never held against the input: nothing is refused for one, and the exit status stays 0 whatever the replay finds. A non-zero status signals a real failure — an unreadable file, a database that will not open, an output that cannot be written. A script that wants to act on the findings reads them from --format json, where a broken file and a game containing an illegal play are not confused with one another.
--render writes the transcription back out as .mat, which is how the round trip is checked on a real file outside the tests.
Only three options write, through the same methods as the Transcription panel: --edit opens a draft on an existing match, --finish finishes it — the match is replaced under the same identifier — and --abandon deletes a draft without a match, and requires --yes for a draft that was never finished, which takes everything typed in it along. An imported match carries analyses and comments that a .mat does not: --edit reports at most their count and refuses without --accept-losses.
Example:
./blunderdb transcribe --mat match.mat --check
# match.mat: 7 point match, 4 game(s), 203 action(s)
# Final score: 9-2
# Inconsistencies: none
tournament — Read a directed tournament
Reads a directed tournament without a graphical interface. Directing one interactively is the job of the Nicomaque engine’s own console; these sub-commands read, none of them waits for input, and only move writes.
./blunderdb tournament <sous-commande> --db <chemin> [options]
Subcommands:
list [--format text|json]— The directed tournaments of the database, with their state, the engine version, the event each belongs to (empty if none) and the date of the last decision.verify --id N [--format text|json]— Replays the direction and reports any remaining warning. Exits in error if one remains: this is the after-the-fact check, and a script that runs it over a season’s databases wants a status code, not a line to filter.standings --id N— The standings as CSV, prizes included, in the language of the interface.page --id N|--rencontre N [--out <folder>]— The HTML display page of a competition (--id), or an event’s wall page (--rencontre: one line per table, whichever competition occupies it). Exactly one of the two is required. Without--outit goes to standard output; with it, the page is written into the folder, which becomes the direction’s own, or the event’s.export --id N— The raw event journal, replayable by the engine’s tools. The journal is the whole truth of a direction: the standings, the brackets and the warnings are replayed from it. A tool reading this output needs no blunderDB at all.move --id N --match M --table T [--format text|json]— Changes the table of a running match, like dragging one cell onto another in the grid. If the target table is taken, the two matches swap tables; an out-of-service table is refused. In an event, if the table is taken by another competition, the swap happens between the two competitions: a table change is written in each log. Prints the table of every running match.hall --rencontre N [--format text|json]— All the tables of an event: one line per table, whichever competition occupies it (competition, match, players), then the proposals of each competition. This is the grid that the All tables view of the Direction displays. Tables are grouped by room when the event has any, and named when they have a name.tables --rencontre N|--tournament N [--format text|json]— The table properties (name, room, reserved, assigned to) and the rooms each competition of an event plays in (--rencontre), or the properties of a competition that plays alone (--tournament). Read-only: writing goes throughcall(rencontres.setTables,rencontres.setEventRooms,directions.setTables).
Common options: --db (required), --id (required except for list, page --rencontre hall and tables), --format.
Examples:
./blunderdb tournament list --db base.db
./blunderdb tournament verify --db base.db --id 3
./blunderdb tournament standings --db base.db --id 3 > classement.csv
./blunderdb tournament page --db base.db --id 3 --out /tmp/affichage
./blunderdb tournament page --db base.db --rencontre 1 --out /tmp/evenement
./blunderdb tournament export --db base.db --id 3 > journal.json
./blunderdb tournament move --db base.db --id 3 --match m4 --table 7
./blunderdb tournament hall --db base.db --rencontre 1
./blunderdb tournament tables --db base.db --rencontre 1
trash — The trash
What was deleted, and what it takes to put it back. A delete is still a delete: a JSON snapshot of what disappears is written first, and nothing else in the database knows that table exists — no search filter, no statistic, no retention rule.
./blunderdb trash <sous-commande> --db <chemin> [options]
Subcommands:
list— What is in the trash, most recently deleted first.restore --id N— Puts entry N back and removes it from the trash.discard --id N— Drops entry N now, without restoring it.empty [--older-than D]— Empties the trash, or only what is older than D days.delete --kind K --id N— Deletes an object through the trash, so the gesture can be undone.Kisposition,collectionorcomment.
Common options: --db (required), --kind, --limit (default 50), --format (text or json).
Note
blunderdb delete still deletes with no net: a script that deletes a position expects it gone, and quietly leaving a snapshot behind would grow a file nobody asked to grow. trash delete is the one that keeps the undo.
Restoring a position goes back through the Zobrist deduplication: it never creates a duplicate, but it does not give back the old identifier — the original row no longer exists. A restored position is the same position, at a new number.
Anything older than thirty days is dropped by blunderdb vacuum — never on opening a database.
Examples:
# Supprimer une position en gardant l'annulation
./blunderdb trash delete --db base.db --kind position --id 412
# Voir la corbeille, puis remettre une entrée
./blunderdb trash list --db base.db
./blunderdb trash restore --db base.db --id 3
# Ne garder que ce qui a moins de trente jours
./blunderdb trash empty --db base.db --older-than 30
info — Database metadata
Display metadata and statistics of a database.
./blunderdb info --db <path> [--format <format>]
Options:
--db— Database (required).--format— Output format:textorjson(default:text).
Examples:
./blunderdb info --db base.db
# Database Information
# ==================================================
# Path: /home/jean/bg/base.db
#
# Metadata:
# Version: 2.20.0
# User: Jean
# Description: Matchs de tournoi 2025
# Date of Creation: 2026-09-06 02:43:51
#
# Statistics:
# Positions: 3859
# Analyses: 3855
# Matches: 11
# Games: 61
# Moves: 3766
--format json adds the file’s origin — issuance carries the watermark if there is one, and this machine’s issuer identity:
./blunderdb info --db base.db --format json
{
"issuance": {
"watermarked": false,
"issuerFingerprint": "1186-57FA-060C-9378",
"issuerName": "unger"
},
"metadata": {
"database_version": "2.20.0",
"dateOfCreation": "2026-09-06 02:43:51",
"description": "Matchs de tournoi 2025",
"user": "Jean"
},
"path": "/home/jean/bg/base.db",
"stats": {
"analysis_count": 3855,
"game_count": 61,
"match_count": 11,
"move_count": 3766,
"position_count": 3859
}
}
edit — Edit metadata
Edit the user name, the description or the thresholds of a database.
./blunderdb edit --db <path> [options]
Options:
--db— Database (required).--user— New user name.--description— New description.--clear-user— Clear user name.--clear-description— Clear description.--error-threshold— Error threshold, in millipoints: a decision costing at least this much is an error.--blunder-threshold— Blunder threshold, in millipoints: an error costing at least this much is a blunder.--format— Output format:text(default) orjson({"changes": [...]}).
At least one edit option is required.
Examples:
./blunderdb edit --db base.db --user "Marie" --description "Ma collection"
./blunderdb edit --db base.db --clear-description
./blunderdb edit --db base.db --error-threshold 20 --blunder-threshold 80
verify — Verify integrity
Verify database integrity and optionally compare a match against its source file.
./blunderdb verify --db <path> [--match <id>] [--mat <file.mat>]
Options:
--db— Database (required).--match— Match ID to verify.--mat— MAT file to compare against (used with--match).--format— Output format:text(default) orjson(statistics, orphans, schema drift, and the match check if any).
Without --match, the command displays general database statistics. With --match, it verifies the match data and can compare it against the original source file.
Every run also checks referential integrity: it counts orphaned rows — games without a match, moves without a game, move analyses without a move, analyses without a position, review-journal entries without a deck or without a position — and prints a WARNING line with the total when any exist. A healthy database answers Orphaned rows: none. Orphans can linger in a database written by a version that did not enforce foreign keys on every connection, or before the review journal had its own; they belong to no match and to no deck, and only take up space. The command still exits with code 0.
Every run also compares the schema against the reference DDL and lists the tables, columns and indexes the database lacks. Opening a database adds what is missing when it can and only logs what it cannot add (typically a UNIQUE index that duplicate rows keep it from rebuilding): this is where that gap becomes visible, and a query naming one of those elements fails until the cause is fixed. A healthy database answers Schema: matches the reference DDL. Like orphans, schema drift is a finding, not a failure: the exit code stays 0.
Every run finally checks the rules the current DDL states but SQLite cannot add to a table that already exists: the range CHECK constraints (dice between 0 and 6, non-negative cube and pip counts, 0 to 15 checkers off, review ratings between 1 and 4), the Zobrist hash a row should never be without, and one analysis per position. A database created since schema version 2.18.0 enforces them; an older one can still hold rows a new database would refuse, and those are what is counted here, rule by rule. A healthy database reports Constraints: every row satisfies the current DDL. One more finding: nothing is repaired and the exit code stays 0.
Every run finally recomputes the two denormalised counters, match.game_count and game.move_count, from the rows they claim to count, and reports how many disagree and by how much at worst. Both are written once, at import, from what the source file held, and are what the match list and the game view display: a small gap is usually an import that skipped what it could not convert. Nothing is rewritten — replacing the counter with what was stored would erase the very discrepancy worth looking at. A healthy database reports Counters: game_count and move_count agree with the rows.
Examples:
./blunderdb verify --db base.db
./blunderdb verify --db base.db --match 1
./blunderdb verify --db base.db --match 1 --mat original.mat
Using it as a guard. The exit code is 0 whatever the command finds: it is --format json that carries the verdict, and a script must read the counters itself.
{
"stats": {
"analysis_count": 3855,
"game_count": 61,
"match_count": 11,
"move_count": 3766,
"position_count": 3859
},
"orphans": {
"games_without_match": 0,
"moves_without_game": 0,
"move_analyses_without_move": 0,
"analyses_without_position": 0,
"reviews_without_deck": 0,
"reviews_without_position": 0
},
"orphan_total": 0,
"schema_drift": {
"missing_tables": null,
"missing_columns": null,
"missing_indexes": null
},
"schema_drift_count": 0,
"constraint_violations": [
{"name": "position.zobrist_hash NOT NULL", "count": 0},
{"name": "position.dice_1 BETWEEN 0 AND 6", "count": 0}
],
"constraint_violation_total": 0,
"counter_drift": {
"matches_with_wrong_game_count": 0,
"games_with_wrong_move_count": 53,
"worst_game_count_gap": 0,
"worst_move_count_gap": 2
},
"counter_drift_total": 53
}
Three fields are worth an alarm: orphan_total, schema_drift_count and constraint_violation_total. When non-zero, they describe a database that needs repairing.
./blunderdb verify --db base.db --format json \
| jq -e '.orphan_total == 0 and .schema_drift_count == 0 and .constraint_violation_total == 0'
counter_drift_total is not one of them, and the example above shows why: the database that produced it had just been imported and already shows 53 games whose move counter differs from what the rows contain. These counters come from the source file, not from the database; a gap tells the story of the import, it does not signal corruption. Look at it, do not use it as a threshold.
vacuum — Compacting the database
Reclaims the disk space left behind by deletions (matches, tournaments, purges): SQLite never shrinks the file by itself when data is deleted, it has to be asked explicitly. This is the only way to trigger a compaction — it never happens automatically when a database is opened, because its cost is unpredictable on a large one.
./blunderdb vacuum --db <path>
Options:
--db— Database (required).--format— Output format:text(default) orjson({"size_before", "size_after", "reclaimed"}, in bytes).
The command starts with a wal_checkpoint(TRUNCATE) so that the size reported before compaction is honest, checks that roughly twice the file’s current size is left on disk (SQLite rebuilds the whole database before switching to it), runs the VACUUM and then an ANALYZE to refresh the statistics the query planner uses. If disk space is short, the command refuses to start with an explicit message rather than risk an interrupted compaction.
Example:
./blunderdb vacuum --db base.db
# Compacting database...
# Before: 128.4 MiB
# After: 41.2 MiB
# Reclaimed: 87.2 MiB
repair — Recompute what is derived
Recomputes what the database derives from what it stores: the scalar columns of every analysis from the analysis itself, of which they are only a projection; each position’s phase and game type from its board; and the Crawford sentinel of every score from the match the position came from, or from the XGID it came in with. The analyses are not touched: what is redone are the values that had been derived from them.
./blunderdb repair --db <path>
Options:
--db— Database (required).--format— Output format:text(default) orjson— one counter per pass:repaired(analysis columns),phases(reclassified positions) andcrawford(rehashed positions). Each gives the number of rows actually changed.
Useful after a correction to the way an imported analysis is read. It has already happened twice. The XG importer writes a “no double” two different ways, and the second was understood as a real double — the column then carried the error of a double that never took place. And an analysis blunderDB had computed itself did not know which move had been played, so a match imported without an analysis kept a zero error everywhere and a PR of 0.00; the column is now recomputed from the match’s moves. Correcting the reading changes nothing in the rows already written; this command redoes them.
The Crawford pass, for its part, touches the positions themselves. A score of 1 means “one point to go, and this game IS the Crawford game”; 0 means “one point to go, Crawford behind us”. Until the importers wrote that distinction, every post-Crawford position was recorded as a Crawford one, and therefore read with a dead cube — where the trailer in fact doubles at the first opportunity. Correcting the score changes the position’s hash: the row is therefore rehashed, and merged with its correct twin if the database already holds one — the analysis, the comments, the collections, the Anki cards and their review journal, the match moves and the trash entries that name it follow the surviving row. A position no match points at is corrected only on the word of the XGID it brought from another program (XG, BGBlitz…): when that XGID’s Crawford field says the game is not the Crawford one, and the XGID does describe this position. The other way round, a position without a match stored at 0 on both sides moves to 1 when the XGID it brought is that of a 1-point match and describes it: the only game of a 1-point match starts one point from the goal, so it is the Crawford game, as the importers write it. The DMP after the Crawford game of a longer match stays at 0: its XGID gives the length of that match. An XGID blunderDB rewrote itself only repeats the stored score and proves nothing. Any other position without a match is left as it is: nothing contradicts what its score announces.
Nothing triggers it automatically, and that is deliberate: rewriting everyone’s analysis columns, or rehashing positions, on the mere act of opening a database is not something a tool should do behind its user’s back.
Example:
./blunderdb repair --db base.db
# 42 analyses repaired.
# 7 positions reclassified.
# 3 positions rehashed onto the right Crawford sentinel.
delete — Delete data
Delete a match and all associated data (games, moves, analyses).
./blunderdb delete --db <path> --type match --id <id> [--confirm]
Options:
--db— Database (required).--type— Delete type:match(required).--id— ID of the item to delete (required).--confirm— Delete without asking for confirmation.--format— Output format:text(default) orjson({"match_id": N, "deleted": true}).
Examples:
# Confirmation interactive, puis sans confirmation (scripts)
./blunderdb delete --db base.db --type match --id 1
./blunderdb delete --db base.db --type match --id 1 --confirm
healthcheck — Probing a daemon
Asks a running serve daemon (see Headless mode (server)) whether it is ready: one GET /readyz request, exit code 0 when the daemon answers 200 (storage reachable, schema at the expected version), 1 otherwise — storage unreachable, stale schema, or nothing listening at the address. No database file is opened.
./blunderdb healthcheck [--addr host:port] [--timeout 2s]
Options:
--addr— Address the daemon listens on (defaultBLUNDERDB_ADDR, else:8080). An address without a host (:8080) or with a wildcard host (0.0.0.0,[::]) is probed on the loopback interface.--timeout— Give up after this long (2sby default).
This is the command the container image’s HEALTHCHECK runs (a distroless image, without curl); the serve binary built from cmd/serve understands it too. It works just as well from a script or a systemd unit.
Example:
./blunderdb serve --db base.db --addr 127.0.0.1:8080 &
./blunderdb healthcheck --addr 127.0.0.1:8080 && echo "démon disponible"
# ready
On failure the reason is printed, which docker inspect reproduces for an unhealthy container:
Error: healthcheck: http://127.0.0.1:8080/readyz answered 503 Service Unavailable (version_mismatch)
mcp — Offer the database to an AI assistant
Serves the database tools to an AI assistant through the Model Context Protocol, over standard input and output: the assistant launches the command. The tools search positions in the command-bar grammar, read a position and its analysis, explain an error, compute a player’s statistics, list matches, tournaments and collections, and ask a quiz question. They only read, except with --write. The full list and the daemon’s HTTP equivalent: Tools for an AI assistant (MCP).
./blunderdb mcp --db base.db [--write]
Options:
--db— Database file (required).--write— Also offers the tools that write: save a position, comment it, create and fill a collection. Nothing is deleted.
Like call, the command migrates an older database’s schema when it opens it, even without --write.
Example: declare the database to Claude Code.
claude mcp add blunderdb -- blunderdb mcp --db /chemin/vers/base.db
completion — Shell completion
Prints a shell completion script for the subcommand names to standard output. The command list embedded in every script is generated from the same table blunderdb help and main.go’s dispatch read (handlers()): a new subcommand is therefore offered by completion as soon as it is wired in, with nothing to keep up to date by hand.
./blunderdb completion <bash|zsh|fish>
Examples:
# bash
source <(blunderdb completion bash)
blunderdb completion bash | sudo tee /etc/bash_completion.d/blunderdb > /dev/null
# zsh : un répertoire déjà sur $fpath
blunderdb completion zsh > "${fpath[1]}/_blunderdb"
# fish
blunderdb completion fish | source
Packages install this automatically: the .deb/.rpm (nfpm) and the AUR package generate the three scripts from the packaged binary at build time, and the Homebrew cask runs blunderdb completion <shell> once at install time via generate_completions_from_executable. Nothing is committed to the repository, so completion can never drift from the subcommand table.
version — Show the version
Prints blunderDB’s version and the database schema version this binary writes; it is the first thing to attach to a bug report.
./blunderdb version
# blunderDB version 0.36.0 (database schema 2.20.0)
Workflow examples
Import a tournament directory
./blunderdb create --db tournoi_paris.db --user "Jean" --description "Open de Paris 2025"
./blunderdb import --db tournoi_paris.db --type batch --dir ./matchs_open_paris/
./blunderdb list --db tournoi_paris.db --type stats
Regular backup
./blunderdb export --db production.db --type database --file sauvegarde-$(date +%Y%m%d).db
Error analysis
# Les positions délicates, puis celles de videau
./blunderdb search --db production.db --error-min 0.1 --export blunders.db
./blunderdb search --db production.db --decision cube --error-min 0.05 --export cube_errors.db
# Les coups réellement fautifs : au moins 100 millièmes d'équité perdus
./blunderdb search --db production.db --move-error-min 100 --format json
Exit codes
0— Success.1— Error.
This makes the CLI suitable for use in scripts with error handling:
if ./blunderdb import --db base.db --type match --file match.xg; then
echo "OK"
else
echo "KO"
exit 1
fi