↑ Top
For the shell user who has never run atuin

Your shell history
becomes a database.

Atuin replaces the append-only text file behind with a SQLite store that knows where, when, how long, and whether it worked — then optionally syncs it, end-to-end encrypted, between your Mac and your WSL box.

Verified against atuin 18.19.0 atuin 18.21.0 now available · not yet re-verified macOS 15.6 · Ubuntu/WSL notes single file · works offline 3 interactive tools · 6 diagrams 14 labs · sandboxed, your real history untouched

1 · Mental model: atuin is a database your shell writes to

Atuin is not a nicer Ctrl+R. It is a database, plus a command-line client your shell reports to every time you run something. Every command generates a row, and every row carries the context that the plain history file throws away: the directory you were in, the exit code, how long it took, which machine, which shell session, and which shell.

That single change — text file becomes table — is where every other feature comes from. Search modes exist because you can query a table. Filter modes exist because rows have a cwd and a session. Sync exists because rows can be serialized, encrypted, and shipped. Statistics exist because you can GROUP BY. If you hold on to "it is a table", the rest of this guide is mostly vocabulary.

The problem it solves

Your shell's native history is a flat file appended at the end of each session. It has four structural defects, and every one of them is a design consequence rather than a bug:

Native history defectWhy it happensWhat atuin does
Two terminals overwrite each other's historyThe file is written wholesale at shell exit; last writer winsEach command is an immediate INSERT; concurrent sessions never collide
You lose everything past HISTSIZEThe file is truncated to a fixed line countNo cap. A 5 MB database holds tens of thousands of commands (§21)
You can't tell which of two similar commands workedExit codes are never recordedexit is a column; --exclude-exit 0 shows only failures (§8)
"What did I run in this project?" is unanswerableNo directory is storedcwd is a column; directory filter mode uses it (§7)

The architecture in one picture

Atuin architecture: hooks, client, daemon, stores, sync your shell zsh · bash · fish · nu preexec → history start precmd → history end Ctrl-R → search TUI exec atuin client one binary, many subcommands filters · secrets crypto · TUI daemon optional · long-lived unix socket local stores records.db append-only encrypted log history.db queryable materialised view encrypted sync server api.atuin.sh, or your own box stores ciphertext only — never keys Everything left of the sync server is offline-capable. Atuin works fully with no account, no network and no daemon. Each is opt-in.
Solid arrows: the default path. Dashed: the daemon path, used only when daemon.enabled = true. The sync server never receives a key.
The load-bearing idea Atuin's client is stateless between invocations. Your shell calls atuin history start before a command and atuin history end after it. That is the entire recording mechanism. Everything else — search, sync, stats — is a separate program reading the same database.

What atuin does not do

Naming the boundary early prevents three weeks of confusion:

  • It does not replace your shell. zsh is still zsh. Atuin adds hooks and rebinds two keys.
  • It does not record command output — not by default. That needs pty-proxy plus the daemon, and even then output lives in memory, not the database (§18).
  • It does not remove your native history file. ~/.zsh_history keeps growing alongside. Atuin reads it once at import and then ignores it.
  • It is not a secrets vault. It filters some obvious credential shapes on the way in (§12), which is a seatbelt, not a safe.
Misconception: "atuin syncs my history to the cloud by default" It does not. A fresh install has no account, and auto_sync has nothing to sync to. You must run atuin register or atuin login deliberately. Until you do, atuin status answers with an error — that error is the correct state for a local-only install:
atuin status
Error: You are not logged in to a sync server - cannot show sync status

Sources: Atuin docs — overview · Getting started

2 · Prerequisite floor

Atuin sits at the intersection of four things you may know unevenly: shell startup files, regular expressions, TOML, and SQLite. You do not need depth in any of them, but you need a floor in each, because atuin's failure modes are usually failures in one of the four rather than in atuin.

Hard prerequisites

Editing your shell rc file

You must be able to open ~/.zshrc or ~/.bashrc, add a line, and reload the shell. Installation is literally one line in that file.

Refresher: exec $SHELL restarts your shell in place; a fresh terminal window works too.

Reading a regular expression

Exclusion filters are regexes (§12). You need ^, $, ., *, and character classes. You do not need lookahead.

Diagnostic: what does ^ls$ match that ^ls does not exclude? (Answer: ^ls$ matches only a bare ls; ^ls would also swallow ls -la.)

TOML table syntax

Atuin's config is TOML, and TOML's one sharp edge bites atuin users constantly: every key after a [table] header belongs to that table. See the callout in §11 — this is the single most common self-inflicted config bug.

Basic SQL SELECT

Only needed for §21, but that section is where atuin stops being a tool and becomes yours. SELECT … FROM … WHERE … GROUP BY … ORDER BY is enough.

Soft prerequisites

  • fzf-style fuzzy syntax. Atuin's fuzzy mode implements the same operators ('exact, ^prefix, suffix$, !negate, |). If you already use fzf, §6 will feel like coming home.
  • Shell hooks. Knowing what preexec and precmd are makes §4 obvious rather than magical. If you don't, §4 teaches them.
  • SSH and reverse proxies. Only for self-hosting (§15).

Just-in-time concepts (taught where needed)

Unix domain sockets (§16), end-to-end encryption and key custody (§14), OSC 133 prompt markers (§18), and the Model Context Protocol (§19). Each is introduced at the point of use with only as much depth as atuin requires.

Self-assessment

Answer these before continuing. If you miss more than one, read the linked refresher first — not because atuin is hard, but because the failure will look like an atuin bug when it isn't.

1. Where does an interactive zsh read its per-user startup config, and where does an interactive bash?

~/.zshrc for zsh. For bash it is ~/.bashrc for interactive non-login shells; login shells read ~/.bash_profile (which conventionally sources ~/.bashrc). On Ubuntu under WSL you get a login shell on first launch, so if your ~/.bashrc is not sourced from ~/.bash_profile, atuin will silently not load. That is the number-one WSL install failure.

2. In TOML, what table does foo = 1 belong to if it appears after a [bar] header?

bar. It becomes bar.foo. TOML has no way to return to the top level except by starting the file over. Consequence: append a top-level atuin key to the bottom of your config and it lands in whatever table is last — silently, with no error.

3. What is the difference between an exit code of 0, 1, 127 and 130?

0 success · 1 generic failure · 127 command not found · 130 terminated by Ctrl+C (128 + SIGINT 2). Atuin stores all of them, which makes --exclude-exit 0 a genuinely useful debugging filter.

4. What does a leading space do to a command in zsh or bash?

With HIST_IGNORE_SPACE (zsh) or HISTCONTROL=ignorespace (bash) set, the command is not written to history. Atuin honours the same convention — a leading space is the fastest one-off exclusion (§12).

5. What does count(distinct command) return that count(*) does not?

The number of unique command strings rather than the number of rows. This distinction matters in §9, where atuin's own "Unique commands" line counts something subtly different again.

Time budget Foundations (§1–§4) is about 45 minutes of reading plus 20 of labs. Daily work (§5–§10) is where fluency lives — budget two sessions. Sync (§14–§15) is 30 minutes if you use the hosted server, an evening if you self-host. Everything from §16 on is optional surface you can return to. A realistic path to working fluency is three sessions of 60–90 minutes; comprehensive mastery including self-hosting and the AI surface is 6–8.

3 · Install & verify, on macOS and on Ubuntu/WSL

Installation is two steps that people routinely conflate: put the binary somewhere, then wire it into your shell. The binary alone does nothing — no hooks, no keybindings, no recording. Almost every "atuin isn't recording anything" report is a missing or misplaced second step.

1 · Binarybrew, the install script, or your distro
2 · Shell initone eval line in your rc file
3 · Importpull in the history you already have
4 · Verifyatuin doctor and one real search

macOS

Homebrew is the right default: it puts atuin on your upgrade path with everything else and lands the binary in a directory already on PATH.

# preferred on macOS
brew install atuin

# alternative: official install script (also works on Linux)
curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh

Then the shell wiring. For zsh — the macOS default since Catalina:

echo 'eval "$(atuin init zsh)"' >> ~/.zshrc
exec $SHELL
Put the init line last, not first atuin init zsh emits 326 lines that rebind Ctrl+R and and register zsh-autosuggestions strategy. Anything sourced after it that also binds those keys wins. If you use Oh My Zsh, Prezto, or a plugin manager, the atuin line goes after the framework's init. The one documented exception is pty_proxy (§18), which wants to be early.

Ubuntu, including under WSL

The same binary, one extra consideration. Ubuntu's apt repositories lag well behind atuin's release cadence — 18.19.0 shipped on 2026-08-03, and three prereleases landed in the four days after — so use the install script or Homebrew-on-Linux rather than apt, unless you specifically want to be pinned.

curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh

# bash: one line, and atuin bundles bash-preexec for you since 18.18.0
echo 'eval "$(atuin init bash)"' >> ~/.bashrc
exec $SHELL
Why the bash init script is 1371 lines and the zsh one is 326 zsh ships add-zsh-hook with preexec/precmd built in. Bash has no such thing, so atuin embeds a copy of bash-preexec (v0.6.0) directly in its init output. Measured with atuin init bash | wc -l on 18.19.0. If you already load bash-preexec yourself, suppress the bundled copy:
eval "$(ATUIN_NO_BUILTIN_PREEXEC=1 atuin init bash)"
The bash integration requires bash ≥ 3.1; the init script guards on BASH_VERSINFO and prints atuin: requires bash >= 3.1 for the integration. if not. Ubuntu ships bash 5.x, so this only bites on macOS's ancient system bash.

Three WSL-specific things

ConcernWhat happensWhat to do
.bashrc not sourced WSL launches a login shell. A login bash reads ~/.bash_profile, not ~/.bashrc. Ubuntu's stock ~/.profile does source ~/.bashrc — but only if no ~/.bash_profile exists. Create one and you break the chain. Confirm with echo $ATUIN_SESSION in a new WSL terminal. Empty means the init never ran.
The daemon and systemd Atuin's daemon can use systemd socket activation (daemon.systemd_socket = true). WSL2 only runs systemd if systemd=true is set under [boot] in /etc/wsl.conf. Simplest path: leave the daemon off, or use daemon.autostart = true with a plain socket path (§16).
Clock skew WSL2's clock has historically drifted after the Windows host sleeps. Atuin orders and de-duplicates by nanosecond timestamp, so a skewed clock produces history that sorts wrongly and syncs oddly. Check date against your Mac after a resume. Recent WSL builds fix this; older ones need sudo hwclock -s.
Verification honesty Every transcript in this guide was produced on macOS 15.6 (Darwin 24.6.0, x86_64) with atuin 18.19.0. The WSL rows above are reasoned from the same binary plus documented WSL behaviour; they were not executed under WSL for this guide. Where a claim is doc-derived rather than measured, it is marked.

Verify

atuin doctor is the one command to run after any install or upgrade. It emits JSON meant for bug reports, but the top of it is the health check you want:

atuin doctor
Atuin Doctor
Checking for diagnostics

Please include the output below with any bug reports or issues

{
  "atuin": {
    "version": "18.19.0",
    "commit": "",
    "sync": null,
    "sqlite_version": "3.46.0",
    "daemon_enabled": true
  },
  "shell": {
    "name": "zsh",
    "default": "zsh",
    "plugins": [
      "atuin"
    ],
    "preexec": "built-in"
  },
  ...

Read four fields and ignore the rest:

  • shell.plugins containing "atuin"the init line ran. If this array is empty, nothing else matters; fix step 2.
  • shell.preexec"built-in" on zsh, "bash-preexec" on bash. This is the recording mechanism.
  • atuin.syncnull means no account. Expected on a fresh install.
  • atuin.daemon_enabled — whether the daemon path is in play (§16).

Where things live

atuin info
Config files:
client config: "/Users/you/.config/atuin/config.toml"
server config: "/Users/you/.config/atuin/server.toml"
client db path: /Users/you/.local/share/atuin/history.db
key path: /Users/you/.local/share/atuin/key
meta db path: /Users/you/.local/share/atuin/meta.db

Env Vars:
ATUIN_CONFIG_DIR = "None"

Version info:
version: 18.19.0
commit:  
Verified doc drift: atuin info lies about the config path With ATUIN_CONFIG_DIR set to a sandbox, atuin info still prints client config: "/Users/you/.config/atuin/config.toml" — the default path — while the Env Vars block below correctly shows the override, and the database paths correctly reflect the sandbox config that was actually read. Measured on 18.19.0. Trust the Env Vars line and the db paths, not the client config line. Use atuin config print to see what atuin actually loaded.
Lab 1 · Build a throwaway atuin
10 minutes · fully isolated · your real history is never touched

Every lab in this guide runs inside a sandbox so you can delete history, break configs, and start daemons without consequence. Build it once now. The mechanism is ATUIN_CONFIG_DIR plus a config that redirects every data path.

# 1 · make the sandbox
export LAB=~/atuin-lab
rm -rf "$LAB"; mkdir -p "$LAB/config" "$LAB/data"

# 2 · a config that points every path into the sandbox
cat > "$LAB/config/config.toml" <<EOF
data_dir = "$LAB/data"
db_path = "$LAB/data/history.db"
key_path = "$LAB/data/key"
session_path = "$LAB/data/session"
auto_sync = false
update_check = false

[daemon]
enabled = false
EOF

# 3 · point this shell at it
export ATUIN_CONFIG_DIR="$LAB/config"
export ATUIN_SESSION=$(atuin uuid)

Expect: nothing printed. Now prove the isolation held:

atuin config print | head -2
data_dir = "/Users/you/atuin-lab/data"
db_path = "/Users/you/atuin-lab/data/history.db"

Expect: both paths under $LAB. If you see ~/.local/share/atuin, the export did not take — stop and fix it before any other lab.

  • atuin config print shows sandbox paths
  • atuin history list prints nothing (empty database)
  • Opening a new terminal and running atuin history list shows your real history — proving the sandbox is per-shell
The sandbox lives in one shell only ATUIN_CONFIG_DIR is an environment variable, so it dies with the shell. That is the feature: to leave the sandbox, close the terminal. To return, re-export the two variables. Nothing in these labs writes to ~/.local/share/atuin.

Teardown when you are done with the guide: rm -rf ~/atuin-lab.

Sources: Installation · doctor · init · info

4 · Anatomy of a record: what atuin actually stores

A history entry in atuin is a row with twelve columns. Learning them is not trivia — every filter, every search flag, and every interesting SQL query in §21 is a predicate over one of these columns. Read this section once and the rest of the tool stops having surprises.

How a row gets written

Two shell hooks bracket every command. preexec fires after you press Enter but before the command runs; precmd fires after it finishes, before the next prompt. Atuin's init script attaches to both.

The command lifecycle: history start, execution, history end preexec fires atuin history start writes command, cwd, session, hostname, shell, author, timestamp returns a UUIDv7 id your command runs atuin is not involved The row already exists, incomplete. A crash here leaves duration = 0. precmd fires atuin history end --exit N fills in duration and exit against that id
Two calls, one row. The id returned by start is passed back to end — that is why ATUIN_HISTORY_ID exists in your environment mid-command.

You can drive this by hand, which is exactly what the labs do:

id=$(atuin history start -- "git status")
atuin history end --exit 0 "$id"
atuin history list
2026-08-07 05:33:35	git status	29ms

The twelve columns

Straight from the live schema — sqlite3 history.db .schema on 18.19.0:

CREATE TABLE history (
	id text primary key,
	timestamp integer not null,
	duration integer not null,
	exit integer not null,
	command text not null,
	cwd text not null,
	session text not null,
	hostname text not null, deleted_at integer, author text, intent text, shell text,

	unique(timestamp, cwd, command)
);
ColumnType / unitWhat it is for
idUUIDv7 as textPrimary key. Time-sortable, so ids and timestamps agree. Used by pty-proxy to key captured output.
timestampnanoseconds since epochOrdering and date filters. Nanoseconds, not seconds — divide by 1e9 in SQL (§21).
durationnanosecondsHow long the command ran. 29568000 renders as 29ms. Zero for interrupted or imported rows.
exitintegerExit status. -1 means "unknown" — every imported row has it (§10).
commandtext, may contain newlinesThe literal command line. Multi-line commands are one row containing \n.
cwdabsolute path, or unknownPowers directory and workspace filter modes. unknown on imported rows.
sessionUUIDOne value per shell session, from $ATUIN_SESSION. Powers session filter mode.
hostnamehost:userComposite — measured as macbook.local:you. Powers host filter mode and identifies the machine after sync.
deleted_atnullable nanosecondsDeletion is a tombstone, not a DELETE. This is how deletions propagate over sync (§13).
authortextWho ran it: your username, or an agent name like claude-code when agent hooks are installed (§19).
intenttextFree-text purpose, populated by agent hooks. Empty for commands you type.
shelltextWhich shell ran it. Drives search.shells filtering. Empty on rows written before atuin tracked it.
The uniqueness constraint is why re-importing is safe unique(timestamp, cwd, command) means the same command, in the same directory, at the same nanosecond can only exist once. Running atuin import zsh twice against the same file produces the same four rows — measured: row count stayed at 4 across two imports. You cannot double your history by fumbling the import.

Interactive: the record inspector

Click a column to see the real value from a real row and why it exists. This is the row produced by the git status example above.

The record inspector needs JavaScript. Every value it shows is in the column table above, taken from the same live sqlite3 -line dump.

record inspector · one row from history.dbclick a field
Select a column above. Values are copied verbatim from a live sqlite3 -line dump of atuin 18.19.0.

Two indexes worth knowing about

The schema creates partial indexes on session, cwd and lower(hostname), each with where deleted_at is null. That is the mechanical reason filter modes are fast on a large history, and the reason a tombstoned row costs you nothing at query time. It also tells you something design-level: atuin expects filtering to be the common case, not full scans.

Misconception: "history.db is the source of truth" It is not. records.db — an append-only, encrypted log — is authoritative; history.db is a materialised view built from it. This is why atuin store rebuild history exists and why the sync protocol talks about records rather than commands. See §17. The practical consequence: if history.db is ever corrupt, you rebuild it rather than restore it.
Lab 2 · Seed a history and read the raw row
8 minutes · sandbox only · needs sqlite3

Continue in the Lab 1 shell. Build a small but realistic history spread across directories with varied exit codes — every later lab reuses it.

mkdir -p "$LAB/work/api" "$LAB/work/web" "$LAB/notes"

rec() {                      # rec <dir> <exit> <command...>
  local dir="$1"; shift
  local ex="$1";  shift
  ( cd "$dir" || exit
    local id; id=$(atuin history start -- "$*")
    atuin history end --exit "$ex" "$id" >/dev/null )
}

rec "$LAB/work/api" 0   cargo build --release
rec "$LAB/work/api" 101 cargo test
rec "$LAB/work/api" 0   cargo test --lib
rec "$LAB/work/api" 0   git status
rec "$LAB/work/api" 0   git commit -m 'fix auth token expiry'
rec "$LAB/work/api" 1   git push origin main
rec "$LAB/work/api" 0   git push --force-with-lease origin main
rec "$LAB/work/web" 0   npm install
rec "$LAB/work/web" 1   npm run build
rec "$LAB/work/web" 0   npm run build -- --verbose
rec "$LAB/work/web" 0   git status
rec "$LAB/work/web" 0   docker compose up -d
rec "$LAB/work/web" 0   docker compose logs -f web
rec "$LAB/work/web" 127 kubctl get pods
rec "$LAB/work/web" 0   kubectl get pods
rec "$LAB/work/web" 0   kubectl logs -f deploy/web
rec "$LAB/notes"    0   ls -la
rec "$LAB/notes"    0   grep -rn TODO .
rec "$LAB/notes"    0   rg --hidden TODO
rec "$LAB"          0   ssh deploy@prod-01.example.com
rec "$LAB"          0   ssh deploy@prod-02.example.com
rec "$LAB"          0   psql -h localhost -U app -d appdb
rec "$LAB"          130 tail -f /var/log/syslog
rec "$LAB"          0   curl -s https://api.example.com/health '|' jq .
rec "$LAB"          0   find . -name '*.log' -mtime +7 -delete

Now read one row raw, bypassing atuin entirely:

sqlite3 -line "$LAB/data/history.db" 'select * from history limit 1;'
        id = 019fdbc913de7af1a80200864c933a33
 timestamp = 1786098815966851000
  duration = 29568000
      exit = 0
   command = git status
       cwd = /Users/you/atuin-lab/work/api
   session = 019fdbc913cb7090989e99039b09182d
  hostname = macbook.local:you
deleted_at =
    author = you
    intent =
     shell = 
  • atuin history list | wc -l reports 25
  • timestamp has 19 digits — nanoseconds, not seconds
  • hostname contains a colon separating host from user
  • shell is empty, because atuin history start invoked by hand does not carry shell context the way the real hook does
Why is duration ~25ms when these commands never ran?

Because duration is measured between the start and end calls, not around your command. In the lab you are timing two process spawns. In real use the command sits between them, so the number is real. This is worth knowing: atuin's duration includes the shell's own overhead, not just the program's runtime.

Sources: Basic usage · history list · live sqlite3 .schema on 18.19.0

5 · The search TUI: the loop you will actually live in

Ninety percent of your atuin use is one motion: press Ctrl+R, type three characters, press Enter. The remaining ten percent is knowing which two keys change what "three characters" means. This section teaches the loop; §6 and §7 teach the two keys.

What the default install binds

From the live atuin init zsh output on 18.19.0 — these are the actual bindkey lines, not a paraphrase:

bindkey -M emacs '^r' atuin-search
bindkey -M viins '^r' atuin-search-viins
bindkey -M vicmd '/' atuin-search
bindkey -M emacs '^[[A' atuin-up-search
bindkey -M vicmd '^[[A' atuin-up-search-vicmd
bindkey -M viins '^[[A' atuin-up-search-viins
bindkey -M emacs '^[OA' atuin-up-search
bindkey -M vicmd '^[OA' atuin-up-search-vicmd
bindkey -M viins '^[OA' atuin-up-search-viins
bindkey -M vicmd 'k' atuin-up-search-vicmd
bindkey '?' self-atuin-ai-question-mark

Three surfaces are taken over: Ctrl+R, the up arrow (both ^[[A and ^[OA encodings, plus vi-mode k), and — new and surprising — ?.

The ? key opens the AI assistant On an empty prompt, typing ? no longer inserts a question mark — it launches Atuin AI (§19). This is the single most startling thing about a default 18.x install. The widget only fires when the buffer is empty or contains exactly ?, so grep '?' file still works, but ?Enter at a fresh prompt does not do what four decades of muscle memory expects. Turn it off with --disable-ai on the init line or ai.enabled = false.

Choosing your keybinding posture

The wrong question is "should atuin take over my up arrow?" The right question is "do I want my up arrow to be positional or associative?" Native is positional — it walks backwards through a list. Atuin's is associative — it opens a filtered search seeded with what you have already typed. Those are different tools, and only one of them can own the key.

PostureInit lineYou getYou give up
Full takeover (recommended) eval "$(atuin init zsh)" Both entry points reach the database. with text already typed becomes a prefix search — genuinely better than native. The "press up four times" reflex. Costs about a day.
Ctrl-R only eval "$(atuin init zsh --disable-up-arrow)" Native intact for muscle memory. Two mental models of "history" running at once, permanently. This is the choice that never resolves.
Up-arrow only eval "$(atuin init zsh --disable-ctrl-r)" Keeps Ctrl+R free for fzf or a plugin. The canonical entry point. Rarely the right trade unless you are deliberately layering fzf.
Recording only eval "$(atuin init zsh --disable-up-arrow --disable-ctrl-r --disable-ai)" A pure database with no UI takeover. Useful on servers, or while you evaluate. All interactive benefit. You must call atuin search -i by hand.

The three flags are subtractive and compose. Measured: passing all three removes exactly the eleven bindkey lines above and the AI widget definitions — 74 lines of the 326.

Recommendation: full takeover, and mean it Half-measures leave you deciding, every single time, which history to consult. Take both keys for two weeks. If still annoys you after that, add --disable-up-arrow — but decide once, from experience, rather than pre-emptively.

Keys inside the TUI

Once the search UI is open, a second keymap applies. The two that change everything are Ctrl+R and Ctrl+S, which cycle filter and search mode respectively.

KeyActionNote
Ctrl+RCycle filter modeglobal → host → session → workspace → directory → session-preload (§7). Yes: the key that opened the UI now means something else.
Ctrl+SCycle search modeprefix / fulltext / fuzzy / daemon-fuzzy (§6)
EnterAcceptRuns the command immediately when enter_accept = true; otherwise puts it on the prompt. See the trap below.
TabAccept for editingAlways puts the command on the prompt without running it. The safe accept.
Ctrl+N / Ctrl+J / Next result
Ctrl+P / Ctrl+K / Previous result
Ctrl+OOpen the inspectorFull metadata for the selected row — the fastest way to see cwd, host and exit
Ctrl+A then DDelete the selected entryPrefix-key sequence. Repeating it deletes all matching entries (§13)
Ctrl+A then CSwitch to the selected command's contextJumps the filter to that command's session and clears the query. Press again to return.
Ctrl+YCopy to clipboard
Ctrl+U · Ctrl+W · Ctrl+A · Ctrl+EStandard readline line editingClear line · delete word · line start · line end
Esc · Ctrl+C · Ctrl+D · Ctrl+GCancel, restoring your original input
The enter_accept trap, and it is a real one With enter_accept = true, Enter in the search UI executes immediately — no chance to edit, no confirmation. Select rm -rf build/ from the wrong project and it runs in your current directory.

The default is genuinely confusing. Measured on 18.19.0: with no config file at all, atuin config get enter_accept -r resolves to false. But atuin default-config emits enter_accept = true uncommented, so anyone who seeds their config from it gets true. The shipped comment says so outright: "This applies for new installs. Old installs will keep the old behaviour unless configured otherwise."

Do not guess which you have. Run atuin config get enter_accept -v and set it explicitly. If you are new, enter_accept = false plus habitual Tab is the safer starting posture; move to true once selecting the right row is automatic.

The columns you see

The result list is configurable via [ui] columns. The default is ["duration", "time", "command"]. Available types with their default widths: duration (5), time (8, relative — "59m ago"), datetime (16, absolute), directory (20), host (15), user (10), exit (3), command (expands).

# syncing between a Mac and WSL? show which machine each command came from
[ui]
columns = ["duration", "time", "host", "command"]

# debugging? make failures visible at a glance
[ui]
columns = ["exit", "duration", "command"]
Opinionated default for a two-machine setup Once you sync a Mac and a WSL box, host stops being decoration. Half your results will be from the other machine, and a path that exists on one may not exist on the other. Add the host column on day one of sync, not after the first confusing paste.
Lab 3 · Drive the real TUI safely
6 minutes · sandbox · interactive

Unlike every other lab, this one needs a terminal you can type into. Still in the Lab 1 shell:

atuin search -i

Expect: a full-screen list of your 25 seeded commands, newest at the bottom, with a duration column and a relative time column.

  1. Type git. Watch the list narrow.
  2. Press Ctrl+S repeatedly. The mode indicator changes and the result set changes with it — this is §6 in action.
  3. Press Ctrl+R repeatedly. The filter indicator cycles. In directory mode you will see nothing, because your current directory is not one of the seeded ones.
  4. Select any row and press Ctrl+O. The inspector shows the full record from §4.
  5. Press Esc to leave without running anything.
  • Ctrl+S visibly changes which rows match for the query git
  • Ctrl+R visibly changes the filter label
  • Esc returns you to a clean prompt with nothing executed
Use Tab, not Enter, until this is reflex Tab puts the command on your prompt so you can read it before committing. Building that habit now costs nothing and eventually saves you from running the right command in the wrong directory.

Sources: Key binding · Advanced usage · Config reference · live atuin init zsh on 18.19.0

6 · Search modes, and the fuzzy syntax that makes fuzzy usable

A search mode answers one question: given the characters you typed, which stored commands count as matches? Atuin ships four answers. Picking the wrong one is the most common reason people conclude that "atuin's search is bad" — it is usually fuzzy mode doing exactly what fuzzy mode does.

The four modes

ModeA command matches when…Best forFails at
prefixit starts with your query"show me every git command"finding a flag buried mid-command
fulltextit contains your query as a literal substringhunting a hostname, a filename, a flagtypos; you must spell it right
fuzzy defaultyour characters appear in order, gaps allowedthree-letter shorthand: gpmgit push origin mainprecision — see the noise demo below
daemon-fuzzysame as fuzzy, but scored by an in-memory index in the daemonvery large histories where responsiveness mattersrequires the daemon (§16); falls back to plain fuzzy for non-interactive searches

Why plain fuzzy is noisy — measured, not asserted

Searching the 25-row lab history for git in fuzzy mode:

atuin search --search-mode fuzzy --cmd-only git
rg --hidden TODO
find . -name '*.log' -mtime +7 -delete
git commit -m 'fix auth token expiry'
git push origin main
git push --force-with-lease origin main
git status

Two of those six are not git commands. rg --hidden TODO matches because it contains a g, then an i, then a t, in that order. That is not a bug — it is the definition of subsequence matching. The fix is one character:

atuin search --search-mode fuzzy --cmd-only "'git"
git commit -m 'fix auth token expiry'
git push origin main
git push --force-with-lease origin main
git status

The five fuzzy operators

Atuin's fuzzy mode implements fzf's query language. All five verified against 18.19.0 on the lab history:

OperatorMeaningVerified exampleMatched
'termexact substring — turns fuzzy into fulltext for this term'gitthe 4 real git commands, nothing else
^termanchored to the start^gitsame 4 — but would exclude sudo git …
term$anchored to the endmain$git push origin main, git push --force-with-lease origin main
!termnegation — exclude matches!gitcargo/npm/docker/kubectl rows; no git rows
a | bOR within a term group'push | 'buildboth git pushes, cargo build --release, both npm builds

Terms separated by spaces are ANDed, so they compose:

atuin search --search-mode fuzzy --cmd-only "^npm !verbose"
npm install
npm run build

atuin search --search-mode fuzzy --cmd-only "cargo 'test"
cargo test
cargo test --lib
The habit worth building Lead with ' when you know the word. 'kubectl costs one keystroke over kubectl and removes every accidental subsequence match. Save bare fuzzy for the case it is actually good at: you remember the shape of a command but not its spelling.

Interactive: the search simulator

A working replica of atuin's matching engine over the same 25-command history the labs build. Change the mode, type a query, use the operators. The ranking is simplified — real atuin scores fuzzy matches by tightness — but membership (which rows match) follows the same rules as the transcripts above.

The search simulator needs JavaScript. The verified transcripts above this heading demonstrate the same matching rules against the same 25-command history.

atuin search — simulator 25 shown

cwd: ~/atuin-lab/work/api · host: macbook.local · session: 019f…5b73

Setting the mode permanently

# in config.toml — TOP of the file, before any [table] header
search_mode = "fuzzy"

# a different mode when the up-arrow triggered the search
search_mode_shell_up_key_binding = "prefix"
The best default nobody sets search_mode = "fuzzy" with search_mode_shell_up_key_binding = "prefix" gives each key the semantics it should have had all along: continues what you started typing (prefix), Ctrl+R searches everything (fuzzy). Two keys, two jobs, no mode-cycling.
Lab 4 · Search modes head to head
7 minutes · sandbox · non-interactive

Run all four against the same query and read the differences.

for m in prefix fulltext fuzzy; do
  echo "--- $m ---"
  atuin search --search-mode "$m" --cmd-only push
done

Expect: prefix returns nothing — no command begins with "push". fulltext and fuzzy both return the two git push lines. That empty prefix result is the lesson: prefix mode is about the first word, not about anywhere.

# now the noise demo, then the fix
atuin search --search-mode fuzzy --cmd-only git | wc -l
atuin search --search-mode fuzzy --cmd-only "'git" | wc -l

Expect: 6 then 4. Two false positives eliminated by one apostrophe.

  • You can state, without looking, what prefix does that fulltext does not
  • You used ' to tighten a fuzzy search and saw the count drop
  • You tried ! and | at least once each

Sources: Advanced usage — search modes · search reference · measured against 18.19.0

7 · Filter modes: scoping the question before you ask it

A search mode decides how to match. A filter mode decides what to match against. They are orthogonal, they combine, and the filter is usually the one that turns a useless result list into a two-item one. This is the feature native history cannot have, because it has no columns to filter on.

The six scopes

ModeRestricts toColumnUse it when
global defaulteverything, every machineyou genuinely don't know where you ran it
hostthis machine onlyhostnamesynced setups: "that was on the Mac, not the WSL box"
sessionthis shell session onlysession"what have I done in this window?" — the debugging scope
directorythe current working directorycwdproject-specific commands you half-remember
workspacethe enclosing git repositorycwd, walked upsame, but you're in a subdirectory. Needs workspaces = true
session-preloadthis session, plus all global history from before it startedbotha compromise scope: recent context first, everything still reachable
Filter mode scoping: nested scopes versus orthogonal path scopes global every row, every machine host hostname = this machine session session = this shell the narrowest scope directory cwd is exactly here. Cuts across host and session — a directory-filtered search can return rows from your other machine. ~/work/api workspace walks up to the enclosing git repo, so it still matches from a subdirectory. ~/work/api/src/auth → ~/work/api requires workspaces = true
Left: nested scopes, each a strict subset of the one outside it. Right: path scopes, which cut across the nesting. session-preload is the union of session and everything global older than it.
Misconception: "directory mode means this project" It means this exact directory string. Run a command in ~/work/api, then cd src and search in directory mode — nothing. That is what workspace mode is for: it walks up until it finds a git repository and matches the whole tree. If you work in monorepos or nested source trees, workspaces = true is not optional.

Enabling workspace mode

# top level, before any [table] header
workspaces = true

# and control which filters Ctrl-R cycles through, in order
[search]
filters = ["workspace", "directory", "session", "global"]

The [search] filters array is worth tuning. The shipped default is ["global", "host", "session", "workspace", "directory", "session-preload"] — six stops on the cycle, most of which you will never want. Trimming it to the three you actually use makes Ctrl+R cycling fast instead of a lottery. Note that workspace is skipped automatically when you are not inside a repository.

Opinionated default filter_mode = "global" with [search] filters = ["global", "workspace", "session"]. Global is right as a default because you usually don't know where you ran the thing. Workspace is the high-value narrowing. Session is the debugging scope. Host and directory are reachable via the CLI when you specifically need them.

Filtering from the command line

Interactive cycling is for exploration; the flags are for scripts and for precision:

# everything ever run in a specific directory — verified output
atuin search --cwd "$LAB/work/web" --cmd-only
npm install
npm run build
npm run build -- --verbose
git status
docker compose up -d
docker compose logs -f web
kubctl get pods
kubectl get pods
kubectl logs -f deploy/web

# override the configured filter mode for one search
atuin search --filter-mode session --cmd-only

# exclude a directory instead of restricting to one
atuin search --exclude-cwd "$LAB/notes" --cmd-only
Lab 5 · Prove that scope beats query
6 minutes · sandbox · non-interactive

The same three-letter query, answered three ways.

echo "--- global ---"
atuin search --cmd-only "'git"

echo "--- only the web project ---"
atuin search --cwd "$LAB/work/web" --cmd-only "'git"

echo "--- only the api project ---"
atuin search --cwd "$LAB/work/api" --cmd-only "'git"

Expect: four rows globally; one (git status) in web; four in api. The query never changed — only the scope did.

Now the failure that teaches directory mode:

cd "$LAB/work/api"
atuin search --filter-mode directory --cmd-only | wc -l
mkdir -p src && cd src
atuin search --filter-mode directory --cmd-only | wc -l

Expect: 7, then 0. One cd and the scope evaporates. That is the argument for workspaces = true.

  • Same query, three different result counts, driven only by --cwd
  • You watched directory mode return zero rows one level down
  • You can explain why workspace mode would have returned 7 in both places

Sources: Advanced usage — filter modes · search reference · measured against 18.19.0

8 · atuin search as a command-line tool

The TUI is the front door; the CLI is where atuin becomes programmable. Because history is a table, atuin search is effectively a typed query interface with a shell-friendly output formatter — and that turns "what did I run last Tuesday that failed?" from a memory exercise into a one-liner.

The filter flags

FlagEffect
-c, --cwd <PATH> · --exclude-cwdRestrict to / exclude a directory
-e, --exit <N> · --exclude-exit <N>Restrict to / exclude an exit code. --exclude-exit 0 is "show me only the failures"
-b, --before · --afterDate bounds. Accepts human strings like "3 days ago" and "now"
--limit · --offsetPagination
-r, --reverseOldest first
--filter-mode · --search-modeOverride config for this invocation
--shell <SHELL>Only commands run by that shell. Repeatable. --shell "" includes rows with no recorded shell
--author <NAME>Filter by author. Repeatable. Special values $all-user and $all-agent (§19)
--include-duplicatesOff by default. Non-interactive search deduplicates; this restores every occurrence
--timezone / --tzRender times in another zone: local, +9, -05:30

Output shaping

Three flags decide what comes out, and the choice matters more than it looks:

FlagOutputSafe to pipe?
(none)2026-08-07 05:33:54 git status 22ms — tab-separated time, command, durationOnly if no command contains a tab or newline
--cmd-onlyThe command text aloneNo. Multi-line commands become multiple output lines
--print0NUL-terminated recordsYes. This is the correct flag for scripting
-f, --formatA template you defineDepends on your template
Verified trap: --cmd-only | wc -l overcounts A command containing a newline is one row and two output lines. Measured on a 4-row database containing one multi-line command:
sqlite3 history.db 'select count(*) from history;'   → 4
atuin history list --cmd-only | wc -l            → 5
atuin history list --cmd-only --print0 | tr -dc '\0' | wc -c  → 4
Anywhere correctness matters, use --print0 and read with while IFS= read -r -d ''.

The format template

--format takes {command} {directory} {duration} {user} {host} {time} {exit} {relativetime}:

atuin search --limit 3 --format "{time} · {exit} · {directory} · {command}"
2026-08-07 05:33:55 · 0 · /Users/you/atuin-lab · ssh deploy@prod-01.example.com
2026-08-07 05:33:55 · 0 · /Users/you/atuin-lab · psql -h localhost -U app -d appdb
2026-08-07 05:33:55 · 130 · /Users/you/atuin-lab · tail -f /var/log/syslog
Two commands, two different template vocabularies atuin history list --format accepts more variables than atuin search --format: it adds {author}, {intent}, {session} and {uuid}. If you need the session id or the UUID for a script, you must use history list, not search. Verified from both --help outputs on 18.19.0.

Queries worth memorising

# everything that failed, anywhere, ever
atuin search --exclude-exit 0 --cmd-only
cargo test
git push origin main
npm run build
kubctl get pods
tail -f /var/log/syslog

# what did I break yesterday, and where
atuin search --exclude-exit 0 --after "yesterday" --format "{time} {directory} :: {command}"

# the command I ran that DID work, right after one that didn't
atuin search --exit 0 --cmd-only "'kubectl"

# reconstruct a session as a script, oldest first
atuin search --filter-mode session --reverse --cmd-only > session.sh

# everything from the last hour on this machine only
atuin search --after "1 hour ago" --filter-mode host --format "{relativetime} {command}"
The pattern that pays for itself --exclude-exit 0 is the flag nobody discovers on their own and everybody uses once they do. Your failed commands are the ones you actually need to find again — the successful ones you already remember, because they worked.
Lab 6 · Build a "what went wrong" report
8 minutes · sandbox · non-interactive

Compose the filter flags into something you would genuinely run at 6pm.

atuin search --exclude-exit 0 --format "exit={exit}  {directory}  ::  {command}"

Expect: five rows, each with a non-zero exit — 101 for cargo test, 1 for git push origin main and npm run build, 127 for kubctl get pods, 130 for tail -f.

Now the pairing that makes it useful — the typo and its correction:

atuin search --cmd-only --format "{exit}  {command}" "'kub"

Expect: kubctl get pods with exit 127 sitting next to kubectl get pods with exit 0. Native history shows you both and tells you nothing; atuin tells you which one to reach for.

Finally, the safe scripting form:

atuin search --exclude-exit 0 --cmd-only --print0 |
  while IFS= read -r -d '' cmd; do
    printf '[FAILED] %s\n' "$cmd"
  done

Expect: exactly five [FAILED] lines — one per row, regardless of embedded newlines.

  • You produced a failures-only report with directories attached
  • You saw a typo and its fix side by side, distinguished by exit code
  • You used --print0 with a read -d '' loop

Sources: search reference · history list reference · measured --help output on 18.19.0

9 · Stats, n-grams, and wrapped

atuin stats is the feature people show their friends and then never use again. That is a shame, because once you understand what it is actually counting it becomes a decent tool for finding the aliases you should have written and the commands you should have scripted.

atuin stats
[▮▮▮▮▮▮▮▮▮▮] 3 git status
[▮▮▮▮▮▮    ] 2 docker compose
[▮▮▮▮▮▮    ] 2 git push
[▮▮▮▮▮▮    ] 2 cargo test
[▮▮▮▮▮▮    ] 2 npm run
[▮▮▮▮▮▮    ] 2 ssh
[▮▮▮       ] 1 kubectl get
[▮▮▮       ] 1 tail
[▮▮▮       ] 1 git commit
[▮▮▮       ] 1 find
Total commands:   26
Unique commands:  26

Three things that output is not telling you plainly

1 · The bucket names are not commands

git status and docker compose appear as units because of [stats] common_subcommands — a shipped list including apt cargo composer dnf docker dotnet git go ip jj kubectl nix nmcli npm pecl pnpm podman port systemctl tmux yarn. For those, the first argument is folded into the bucket name. Everything else buckets on the first word alone. Two more knobs: common_prefix = ["sudo"] strips a leading sudo, and ignored_commands drops entries entirely (the commented default suggests cd, ls, vi).

2 · A pipeline counts more than once

The lab history has 26 rows, but the buckets sum to 27. The extra one is curl -s https://api.example.com/health | jq ., which contributes to both a curl bucket and a jq bucket. Stats splits on pipes. That is arguably right — you did run jq — but it means bucket counts do not sum to Total commands.

3 · "Unique commands" is not distinct command text

Measured discrepancy The lab database has 26 rows and only 24 distinct command strings (git status appears three times). Yet stats reports Unique commands: 26. Running the obvious SQL:
sqlite3 history.db "select count(*), count(distinct command),
        count(distinct command||'|'||cwd) from history;"
26|24|26
The reported figure matches count(distinct command || cwd)the same command in two directories counts as two unique commands. Confirmed against a second dataset (4 rows, 2 distinct commands in one directory → reported 2). This is inferred from behaviour, not read from source, so treat it as a strong measurement rather than a documented contract. Practical upshot: do not quote "unique commands" as a measure of vocabulary size — it is inflated by however many directories you work in.

N-grams: the alias detector

-n sets how many consecutive words form a bucket. It is the closest thing atuin has to "what should I alias?":

atuin stats -n 2
[▮▮▮▮▮▮▮▮▮▮] 1 curl | jq
Total commands:   26
Unique commands:  26

On a real history with tens of thousands of rows, -n 2 and -n 3 surface the pipelines you retype constantly. On the 26-row lab set there is only one repeated pair, and -n 3 returns no buckets at all — expected, and a useful reminder that stats needs volume to say anything.

Periods and scopes

atuin stats                    # all time
atuin stats today              # relative period
atuin stats -c 30              # 30 buckets instead of 10
atuin stats --filter-mode directory   # only this directory

atuin wrapped

atuin wrapped
Your history for 2025 is empty!
Maybe 'atuin import' could help you import your previous history 🪄
wrapped defaults to last year, not this year Run in August 2026 with a fresh database, it reports on 2025 and finds nothing. Pass the year explicitly — atuin wrapped 2026 — if you want the current one. Undocumented in --help, which shows only [YEAR].
Lab 7 · Find the alias you should have written
5 minutes · sandbox, then your real history

First reproduce the pipeline double-count in the sandbox:

atuin stats -c 30 | sed 's/\x1b\[[0-9;]*m//g' | tail -25

Expect: 20 buckets whose counts sum to 27 against a Total commands: 26. Find curl and jq as separate rows — that is your missing one. (The sed strips ANSI colour so the arithmetic is readable in a pipe.)

Now the part that pays off — open a new terminal so you are back on your real history:

atuin stats -c 20
atuin stats -n 2 -c 20
atuin stats -n 3 -c 10

Expect: real signal. Anything in the -n 2 or -n 3 list you type more than a few times a week is a candidate for atuin dotfiles alias set (§17).

  • You explained why the bucket counts exceed the total
  • You ran n-gram stats against your real history
  • You wrote down at least one alias worth creating

Reminder: the new terminal has no ATUIN_CONFIG_DIR, so it reads your real database. Re-export the Lab 1 variables to go back to the sandbox.

Sources: stats reference · atuin default-config [stats] section on 18.19.0 · measured

10 · Importing the history you already have

Import is a one-time, idempotent, lossy operation. Lossy is the word to sit with: your old history file has no exit codes and no directories, so imported rows arrive with exit = -1 and cwd = unknown. Knowing that up front stops you from concluding later that atuin lost data it never had.

atuin import auto        # detect the current shell and do the right thing
atuin import zsh         # or be explicit
atuin import bash

Twelve importers ship: auto zsh zsh-hist-db bash replxx resh fish nu nu-hist-db xonsh xonsh-sqlite powershell. auto reads $SHELL and dispatches.

What a real import looks like

atuin import zsh
        Atuin
======================
          🌍
       🐘🐘🐘🐘
          🐢
======================
Importing history...
Importing history from zsh
Import complete!

The turtle standing on elephants standing on a world is a Terry Pratchett reference and also the origin of the name: Atuin is the Great A'Tuin, the turtle carrying the Discworld. Turtles all the way down — history all the way back.

What survives, what does not

Importing a four-line zsh extended-format history file and reading the result:

atuin history list --format "{time} | ex={exit} | dur={duration} | cwd={directory} | {command}"
2025-06-15 10:06:40 | ex=-1 | dur=0s | cwd=unknown | ls -la
2025-06-15 10:07:40 | ex=-1 | dur=3s | cwd=unknown | cargo build
2025-06-15 10:08:40 | ex=-1 | dur=0s | cwd=unknown | git commit -m "wip"
2025-06-15 10:09:40 | ex=-1 | dur=0s | cwd=unknown | echo hello
world
Fieldzsh extended historyplain bash history
commandpreserved, including \-continuation multi-line commandspreserved
timestampreal, from the : 1750000000:… prefixsynthesised at import time, 1 ms apart, in file order
durationreal, from the elapsed-seconds field (33s)zero
exit-1 (unknown) — zsh never recorded it-1
cwdunknownunknown
hostnamethe importing machine — not where the command originally ransame
If your zsh history has no timestamps, check EXTENDED_HISTORY zsh only writes the : <epoch>:<elapsed>;<command> form when setopt EXTENDED_HISTORY is on. Without it you get bare command lines and atuin must synthesise timestamps the same way it does for bash — your whole history collapses into a few seconds on import day. Verified: measured bash import timestamps were 1786098986479651000, …480651000, …481651000 — exactly 1 ms apart. Nothing is lost, but relative-time search becomes meaningless for those rows.
Re-importing is safe — measured The unique(timestamp, cwd, command) constraint (§4) makes import idempotent. Running the same import twice left the row count at 4 both times. Import freely; you cannot duplicate your history by fumbling it.

Where import reads from

The zsh importer honours $HISTFILE, which makes it easy to import from a specific file — including one you copied off another machine:

HISTFILE=/path/to/other/.zsh_history atuin import zsh
Migrating from a second machine: import, don't sync If you have years of history on a box you are retiring, copy its .zsh_history over and import it with HISTFILE. Do not set up sync just to move it once. Sync is for ongoing convergence; import is for absorbing a corpus.
Lab 8 · Import, verify the losses, prove idempotency
9 minutes · fresh sandbox · reads only files you create

Use a second sandbox so the seeded history from Lab 2 stays clean.

export LAB2=~/atuin-lab2
rm -rf "$LAB2"; mkdir -p "$LAB2/config" "$LAB2/data"
cat > "$LAB2/config/config.toml" <<EOF
data_dir = "$LAB2/data"
db_path = "$LAB2/data/history.db"
key_path = "$LAB2/data/key"
session_path = "$LAB2/data/session"
auto_sync = false
update_check = false

[daemon]
enabled = false
EOF
export ATUIN_CONFIG_DIR="$LAB2/config"

# a fake zsh history in extended format, including a multi-line command
cat > "$LAB2/fake_zsh_history" <<'EOF'
: 1750000000:0;ls -la
: 1750000060:3;cargo build
: 1750000120:0;git commit -m "wip"
: 1750000180:0;echo hello \
world
EOF

HISTFILE="$LAB2/fake_zsh_history" atuin import zsh

Expect: the turtle banner and Import complete!.

atuin history list --format "ex={exit} dur={duration} cwd={directory} :: {command}"

Expect: four entries, all ex=-1 and cwd=unknown; cargo build alone has dur=3s; the last command spans two output lines.

Now prove idempotency, and prove the line-count trap at the same time:

HISTFILE="$LAB2/fake_zsh_history" atuin import zsh
sqlite3 "$LAB2/data/history.db" 'select count(*) from history;'
atuin history list --cmd-only | wc -l

Expect: 4 from SQL, 5 from wc -l. The database did not grow; the multi-line command is being counted twice by wc. Both facts in one command pair.

  • Imported rows show exit=-1 and cwd=unknown
  • Duration survived only for the entry whose zsh record had an elapsed field
  • A second import did not change the row count
  • You can explain the 4 vs 5 discrepancy

Teardown: rm -rf ~/atuin-lab2, and re-export ATUIN_CONFIG_DIR="$LAB/config" to return to the Lab 1 sandbox.

Sources: import reference · History import guide · measured against 18.19.0

11 · Configuration: config.toml and the config subcommand

Atuin has three layers of configuration truth, and confusing them is the root of most "I set that and nothing happened" reports. There is the built-in default compiled into the binary; there is atuin default-config, a commented template that is not identical to the built-in defaults; and there is your file. Only the third one is yours.

Where the file lives, and how to find out

Default: ~/.config/atuin/config.toml. Override the whole directory with ATUIN_CONFIG_DIR — the mechanism every lab in this guide uses. To see what atuin actually loaded, ignore atuin info (which lies about this, §3) and use:

atuin config print
data_dir = "/Users/you/atuin-lab/data"
db_path = "/Users/you/atuin-lab/data/history.db"
auto_sync = false
update_check = false

[daemon]
enabled = false

Reading and writing values

CommandAnswers
atuin config get KEYWhat is in my file. Prints (not set in config file) when absent
atuin config get KEY -rThe resolved value after defaults and overrides — what atuin will actually use
atuin config get KEY -vBoth, side by side. Use this one when debugging
atuin config set KEY VALUEWrites to your file. Dotted keys work: atuin config set dotfiles.enabled true
atuin config set KEY VALUE -t TYPEForce string / boolean / integer / float instead of guessing
atuin config printThe whole loaded file as TOML
atuin default-config404 lines of annotated template. Read it; don't blindly copy it
The TOML trap that will get you — verified live Appending a top-level key to the end of a config that already has a [table] header silently nests it inside that table. It does not error. It does not warn. It just quietly does nothing.
# WRONG — history_filter lands inside [dotfiles]
[daemon]
enabled = false

[dotfiles]
enabled = true

history_filter = ["^psql"]      ← this is now dotfiles.history_filter
Measured consequence: with this file, atuin history prune --dry-run reported No entries to prune. Moving the same line above [daemon] made it report Found 6 entries to prune. — with no other change.
# RIGHT — every top-level key first, tables last
auto_sync = true
search_mode = "fuzzy"
history_filter = ["^psql"]
cwd_filter = ["/notes"]

[daemon]
enabled = false

[dotfiles]
enabled = true
Diagnostic: after any config edit, run atuin config print and check that your key appears above the first [. This one habit prevents an entire class of bug.

The defaults that matter, resolved from the binary

Measured with atuin config get KEY -r against 18.19.0 with an otherwise-empty config:

KeyResolved defaultWhy you might change it
search_modefuzzySet search_mode_shell_up_key_binding = "prefix" alongside it (§6)
filter_mode(unset — behaves as global)workspace if you live in repos
sync_frequency5m0 syncs after every command
sync_addresshttps://api.atuin.sh/Point at your own server (§15)
auto_syncfalse (in this build)See the drift note below
enter_acceptfalse built-in / true in default-configThe trap from §5. Set it explicitly
keymap_modeemacsvim-insert / vim-normal / auto
secrets_filtertrueLeave it on (§12)
store_failedtrueSet false and you lose the --exclude-exit 0 workflow. Don't
inline_height40Lines the TUI may occupy
show_previewtruePreview pane under the results
stylecompactfull for a bordered UI
workspacesfalseTurn on to enable workspace filter mode (§7)
prefers_reduced_motionfalseOr set NO_MOTION=true in the environment
Verified doc drift: sync frequency The sync guide states that "by default, Atuin syncs hourly". The binary disagrees: atuin config get sync_frequency -r returns 5m, matching the comment in atuin default-config. Trust the binary. When docs and behaviour disagree, atuin config get -r is the arbiter.

Similarly, auto_sync resolved to false in the sandbox used here even though the shipped template comments it as true. Because the sandbox config set it explicitly, that reading is not a clean measurement of the built-in default — check your own with atuin config get auto_sync -v rather than assuming either value.

The eight config sections

Beyond the top-level keys, atuin default-config defines: [stats] (bucket shaping, §9), [keys] (edge behaviour of arrow keys and backspace in the TUI), [preview], [daemon] (§16), [search] (the filters cycle and shells filtering), [tmux] (popup search, needs tmux ≥ 3.2), [ui] (columns and syntax_highlight, §5), plus commented [pty_proxy] and [theme] blocks.

A starting config worth copying

# ~/.config/atuin/config.toml — top-level keys FIRST
search_mode = "fuzzy"
search_mode_shell_up_key_binding = "prefix"
filter_mode = "global"
workspaces = true
enter_accept = false          # explicit; see §5
secrets_filter = true
store_failed = true
sync_frequency = "5m"
update_check = true

history_filter = [
  "^ *$",                      # blank / whitespace-only
  "--password",
  "^export .*(TOKEN|SECRET|KEY)=",
]

[search]
filters = ["global", "workspace", "session"]

[ui]
columns = ["duration", "time", "host", "command"]

[dotfiles]
enabled = true
Lab 9 · Reproduce the TOML trap, then fix it
7 minutes · sandbox · reversible

Back in the Lab 1 sandbox. Break it deliberately — this is the fastest way to make the rule stick.

cat >> "$LAB/config/config.toml" <<'EOF'

history_filter = ["^psql", "^ssh deploy@"]
cwd_filter = ["/notes"]
EOF

atuin config print | tail -6
atuin history prune --dry-run

Expect: config print shows both keys sitting under [daemon], and prune reports No entries to prune. — even though six rows obviously match.

Now rewrite with the ordering rule respected:

cat > "$LAB/config/config.toml" <<EOF
data_dir = "$LAB/data"
db_path = "$LAB/data/history.db"
key_path = "$LAB/data/key"
session_path = "$LAB/data/session"
auto_sync = false
update_check = false
history_filter = ["^psql", "^ssh deploy@"]
cwd_filter = ["$LAB/notes"]

[daemon]
enabled = false
EOF

atuin history prune --dry-run

Expect:

Found 6 entries to prune.
2026-08-07 05:33:55	psql -h localhost -U app -d appdb	27ms
2026-08-07 05:33:55	ssh deploy@prod-02.example.com	30ms
2026-08-07 05:33:55	ssh deploy@prod-01.example.com	25ms
2026-08-07 05:33:55	rg --hidden TODO	23ms
2026-08-07 05:33:55	grep -rn TODO .	27ms
2026-08-07 05:33:55	ls -la	29ms

Three from history_filter (the psql and both ssh lines), three from cwd_filter (everything run in notes/).

  • You saw a top-level key silently swallowed by a table
  • You confirmed the swallow with atuin config print
  • The same filters worked once moved above the first [
  • You did not run prune for real yet — that is Lab 10

Sources: config subcommand · Configuration · atuin default-config on 18.19.0 · measured

12 · Excluding commands, and what the secrets filter really does

A database that records everything records the things you did not mean to record. Atuin gives you four exclusion mechanisms at three different points in the pipeline — and the most important thing to understand is that filters only apply going forward. Changing a filter does not retroactively clean anything; that is what prune is for.

The four mechanisms, in pipeline order

MechanismWhen it actsScopeReversible?
Leading spaceYour shell never tells atuinOne command, decided as you typeN/A — never recorded
secrets_filterAtuin refuses the writeAutomatic pattern match on credentialsOff via config, but leave it on
history_filterAtuin refuses the writeYour regexes against the command textYes, edit the list
cwd_filterAtuin refuses the writeYour regexes against the directoryYes
atuin history pruneAfter the factDeletes existing rows matching the two filters aboveNo — it deletes

1 · The leading space

The oldest trick in shell history, and atuin honours it. Type a space before the command and it is never recorded — by your shell or by atuin.

# zsh — in .zshrc
setopt HIST_IGNORE_SPACE

# bash — in .bashrc
export HISTCONTROL=ignorespace
Documented bash caveat The atuin docs note that bash-preexec "currently has an issue where it will stop honoring ignorespace" — a space-prefixed command may still land in bash's own history file even when atuin correctly skips it. On Ubuntu/WSL under bash, treat the leading space as an atuin-level exclusion, not a system-wide one. On zsh this does not apply.

2 · The secrets filter

On by default. Atuin refuses to record commands matching a built-in credential-shaped pattern set. From the shipped config comments, the categories are: AWS key ids · GitHub PATs (old and new formats) · Slack OAuth tokens (bot and user) · Slack webhooks · Stripe live and test keys.

This is a seatbelt, not a vault The list is short and shape-based. It will not catch your database password, your internal API token, an SSH passphrase typed as an argument, or a curl -H "Authorization: Bearer …" with a bespoke token format. Do not let its existence change your behaviour around secrets on the command line. Add your own history_filter patterns for whatever your organisation actually uses, and prefer environment files and credential helpers over arguments.

3 and 4 · Your own regexes

# these go at the TOP of config.toml — see §11
history_filter = [
  "^ls$",                        # bare ls, but not `ls -la`
  "^cd ",                        # any cd
  "--password",                  # anywhere in the line
  "^export .*(TOKEN|SECRET)=",
]

cwd_filter = [
  "^/tmp",                       # nothing run from /tmp
  "/node_modules/",              # nothing inside any node_modules
  "^/Users/you/clients/",        # a whole client tree
]

Patterns are unanchored. "ls" matches tools and false. Anchor deliberately with ^ and $. This is the single most common mistake with these lists, and the tester below exists to make it visible.

Interactive: the exclusion-filter tester

Type a regex and watch which of the 25 lab commands would be blocked. The engine is JavaScript's RegExp, which shares the constructs you will realistically use with Rust's regex crate — anchors, classes, quantifiers, alternation. Rust's engine has no backreferences or lookaround, so avoid those in real filters.

The filter tester needs JavaScript. The worked regex examples above show the same anchoring rules.

history_filter tester · unanchored, case-sensitive 0 blocked · 25 kept

Cleaning up after the fact: prune

Filters are write-time. To apply a newly added filter to history you already have, atuin history prune deletes every existing row matching your current history_filter and cwd_filter.

atuin history prune --dry-run    # ALWAYS this first
atuin history prune              # then this
Always dry-run, and read every line prune takes no query — it uses whatever your config currently says. A too-loose regex added five minutes ago can delete thousands of rows, and if sync is on, the deletions propagate to every other machine (§13). The dry run is not a formality.

Turning atuin off for one tool

Some tools spawn subshells where recording is noise — a REPL wrapper, an ephemeral container, a script runner. Guard the init line rather than filtering the output:

# in .zshrc — skip atuin entirely inside a specific context
if [[ -z "$MY_TOOL_SESSION" ]]; then
  eval "$(atuin init zsh)"
fi
Lab 10 · Filter, verify, prune
10 minutes · sandbox · destructive inside the sandbox only

Lab 9 left the filters in place and the dry run showing six rows. Now do it for real and confirm what changed.

atuin history list --cmd-only | wc -l      # before
atuin history prune --dry-run | head -1
atuin history prune
atuin history list --cmd-only | wc -l      # after

Expect: 25, then Found 6 entries to prune., then 19.

Now prove the write-time behaviour — the filter blocks new writes, not just old ones:

cd "$LAB"
id=$(atuin history start -- "psql -h db.example.com")
atuin history end --exit 0 "$id"
atuin search --cmd-only "'psql"

Expect: no output. The ^psql filter refused the write.

Finally, see the tombstone that prune left behind — deletion is not DELETE:

sqlite3 "$LAB/data/history.db" \
  "select count(*) total,
          sum(deleted_at is null) alive,
          sum(deleted_at is not null) tombstoned from history;"

Expect: a total larger than the live count, with the difference tombstoned. The rows are still physically present with deleted_at set — that is what makes deletion syncable (§13).

  • Row count dropped from 25 to 19 after prune
  • A newly issued psql command was refused at write time
  • You saw tombstoned rows still present in the table
  • You ran --dry-run before the real prune

Sources: Excluding commands · history prune · atuin default-config secrets list · measured

13 · Deleting history without wrecking your sync

Deletion in atuin is a write, not an erasure. Removing a row sets deleted_at and leaves the record in place, because that tombstone is the only way another machine can learn that you deleted something. Understanding this changes how you think about mass deletion — a hundred thousand deletions is a hundred thousand records to sync.

The four ways to delete

MethodScopePreview first?
Ctrl+A then D in the TUIThe selected entry. Repeating deletes all matching entriesYou can see the row; use Ctrl+O to inspect it first
atuin search --delete QUERYEverything the query matches. Prints nothingYes — run the same query without --delete
atuin history pruneEverything matching your config filters (§12)Yes — --dry-run
atuin history dedupDuplicates sharing command + cwd + hostnameYes — -n / --dry-run
atuin search --delete-it-allEvery row in the local database. Cannot be combined with a queryThere is nothing to preview
--delete prints nothing, so preview is not optional atuin search --delete "aws" gives no output and no confirmation — you cannot tell afterwards what it took. The discipline is mechanical: run the query, read the list, then re-run it with --delete appended.
# 1 · look
atuin search --cmd-only "'aws"
# 2 · only if that list is exactly what you meant
atuin search --delete "'aws"
Remember that fuzzy mode is the default, so a bare aws query matches by subsequence and will take rows you did not expect (§6). Quote it as 'aws.

dedup needs both of its arguments

Unlike the others, dedup has two required flags. Running it bare is an error:

atuin history dedup --dry-run --before "now" --dupkeep 1
No duplicates to delete.
  • --before <DATE> — only consider entries older than this. Accepts "now", "30 days ago", a date.
  • --dupkeep <N> — how many of the most recent copies to keep.

The duplicate key is command + cwd + hostname. The lab history reports no duplicates despite three git status rows, because each was run in a different directory — a good demonstration that dedup is conservative by design.

Deletion and sync

How a deletion propagates across synced machines machine A you press Ctrl-A D UPDATE history SET deleted_at = now a new record, not a gap push sync server stores the tombstone as one more encrypted record cannot read either the command or the deletion pull machine B applies the tombstone; row disappears from search still physically present with deleted_at set Consequence: mass deletion is mass writing. 100,000 deletions means 100,000 records to sync, forever.
The tombstone is why deletions converge across machines — and why "delete everything and start over" is the wrong move on a synced account.
Starting completely fresh on a synced account Do not reach for --delete-it-all. The docs are explicit that it produces an enormous pile of delete records that then sync to every device. The documented clean-slate sequence is to destroy the account instead:
atuin account delete
atuin register -u <username> -e <email>
atuin import auto        # optional: repopulate from your shell history file
atuin account delete is irreversible and removes all server-side data for that account. Doc-sourced; not executed for this guide.
A safer habit than deleting Most "I need to delete that" moments are really "that shouldn't have been recorded". Add the pattern to history_filter and run prune once. You end up with a rule that keeps working instead of a one-off cleanup you will repeat next month.

Sources: Deleting history · search reference · measured dedup --help on 18.19.0

14 · How sync works, and what the server can see

Atuin's sync is end-to-end encrypted, which means something precise and worth stating exactly: the server stores ciphertext it cannot decrypt, and the key never leaves your machines. That single design choice determines the whole operational story — including the part where losing the key loses the history.

Records, not commands

Sync does not ship history rows. It ships records from records.db, an append-only encrypted log. history.db is a materialised view built by replaying that log locally. Two machines converge because they exchange records and each replays them.

atuin store status
host: 019fdbc9-13fa-76d3-8d9b-642ffed4bb06 <- CURRENT HOST
	store: history
		idx: 25
		first: 019fdbc9-13fe-7d12-be70-a004583dcd78
			created: 2026-08-07 5:33:35.998341 -05:00:00
		last: 019fdbc9-6184-7b82-b877-bcbe3fa9a331
			created: 2026-08-07 5:33:55.844827 -05:00:00

Read that as: this host has one store (history) containing 25 records, indexed 0 through 24. After you enable dotfiles or the KV store you will see additional stores listed here — they sync through the same mechanism.

End-to-end encryption model for atuin sync your Mac key file ~/.local/share/atuin/key never transmitted, never derived from your password atuin key → prints it ciphertext sync server what it holds opaque encrypted records, your username, email, password hash, host ids what it cannot read: commands ciphertext your WSL box same key required you paste it during atuin login wrong key → records arrive but stay unreadable Password ≠ key. The password authenticates you to the server. The key decrypts your data. You can reset a forgotten password. You cannot reset a lost key — the records stay encrypted forever.
Two independent secrets with two different jobs. Conflating them is the most consequential misunderstanding in this section.
Back up your key before you set up the second machine
atuin key     # prints the key — treat it like an SSH private key
Put it in your password manager. Not in a note, not in a synced plaintext file, not in a git repo. If you lose it, every record on the server becomes permanently undecryptable — the server operator cannot help, by design. The atuin project states plainly that they will never ask you for it.

The sync commands

CommandWhat it does
atuin register -u USER -e EMAILCreate an account and generate a key locally
atuin login -u USERLog in on another machine. Prompts for password and key
atuin keyPrint the encryption key for transfer
atuin syncSync now
atuin sync -fForce a full re-download. The repair tool when a machine looks incomplete
atuin statusSync status. Errors when logged out — that is normal for local-only
atuin store push / pullOne-way record transfer. Recovery tools, not daily use
atuin store verifyCheck that every record decrypts with the current key. Run this after any key change
atuin store purgeDelete records that cannot be decrypted. Irreversible
atuin store rekeyRe-encrypt with a new key. Flagged in --help as "potential for data loss!"
atuin account change-passwordChange the server password. Does not touch the key
atuin account deleteDelete the account and all synced data. Irreversible
Verification honesty for §14 and §15 No account was registered and no server was run while writing this guide, so the sync transcripts here are doc-sourced, not measured. What was measured: every --help output above, atuin store status, the resolved defaults for sync_address and sync_frequency, and the logged-out error from atuin status. Treat command shapes as verified and server-side behaviour as documented.

Tuning sync frequency

sync_frequency = "5m"    # default (measured)
sync_frequency = "0"     # after every command
sync_frequency = "1h"    # quieter

Sync is triggered opportunistically when a command runs, so the interval is a floor, not a schedule: leave the terminal idle for two hours and nothing syncs until you type something. If you want time-based syncing regardless of activity, that is one of the daemon's jobs (§16).

Set sync_frequency = "0" on a two-machine setup With a Mac and a WSL box you are usually moving between them, and the failure you care about is "I ran it over there five minutes ago and it isn't here". Syncing after every command costs one small HTTPS request against a machine that is already awake. The 5-minute default optimises for a fleet; you have two.

Sources: Sync guide · sync reference · store reference · account reference

15 · Hosted, self-hosted, or no sync at all

The wrong question is "is the hosted server safe?" — end-to-end encryption already answers that for command contents. The right question is "what am I willing to operate?" Sync is a service with uptime, backups and upgrades, and the only real difference between the three options is who carries that.

The decision

Hosted (api.atuin.sh)Self-hostedNo sync
Setup effortTwo commandsAn evening, plus a reverse proxy and TLSZero
Ongoing effortNoneBackups, upgrades, cert renewal, diskNone
Server sees your commandsNo — ciphertext onlyNo, and it is your boxN/A
Server sees metadataUsername, email, password hash, host ids, record counts and timingSame, on hardware you controlNothing
Multi-machine historyYesYesNo — the whole point of your setup
Survives a laptop dyingYes, if you kept the keyYes, if you kept the key and the backupsNo
Fails whenThe service is down — locally you are unaffectedYour box is down, or your cert expiredNever
CostFree tierWhatever the box costsFree
Recommendation for a Mac + WSL pair: hosted You have two machines, both of which you use interactively, and the data is encrypted before it leaves either one. Self-hosting adds an operational burden whose only return is not trusting a service with ciphertext it cannot read. Self-host when you have a policy requirement, a homelab you already run, or an air-gapped network — not as a default posture. Revisit if you ever put atuin on servers.

Path A · Hosted, end to end

On the machine that already has the history you care about — your Mac:

atuin register -u <username> -e <email>
atuin sync
atuin status
atuin key          # copy this into your password manager NOW

Then on the WSL box:

atuin login -u <username>
# prompts for your password, then for the key from the Mac
atuin sync -f       # force a full download on first sync
atuin store verify  # confirm every record decrypts
Do the import before the second machine joins Import your Mac's .zsh_history and your WSL box's .bash_history locally on each machine first, then sync. Doing it the other way round means you sync, then import, then sync again — twice the records for the same commands. Order: import → register/login → sync.
What your two machines will look like afterwards Every command appears on both, tagged by hostname as host:user (§4). Directory filter mode becomes noticeably less useful, because /home/you/project on WSL and /Users/you/project on macOS are different strings for the same work. Two mitigations: add the host column (§5) so you can see which machine a row came from, and lean on workspace mode, which keys on the repository rather than the absolute path.

Path B · Self-hosting

Since 18.12.0 the server is a separate binary. atuin server start no longer exists — if you find a tutorial using it, it predates the split.

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/atuinsh/atuin/releases/latest/download/atuin-server-installer.sh | sh

atuin-server start

Configuration comes from ~/.config/atuin/server.toml or the environment:

# server.toml — PostgreSQL
host = "0.0.0.0"
port = 8888
open_registration = true
db_uri = "postgres://user:password@hostname/database"

# or SQLite, for a small personal instance
db_uri = "sqlite:///config/atuin.db"
# the same via environment variables
export ATUIN_HOST="0.0.0.0"
export ATUIN_PORT=8888
export ATUIN_OPEN_REGISTRATION=true
export ATUIN_DB_URI="postgres://user:password@hostname/database"
Server keyDefaultNotes
host127.0.0.1Loopback by default — deliberate. Bind 0.0.0.0 only behind a proxy
port8888
open_registrationfalseTurn on, register your accounts, turn back off and restart
db_urirequiredPostgreSQL or SQLite
pathemptyRoute prefix when mounted under a subpath
There is no built-in TLS The project recommends terminating TLS at nginx, Caddy or Traefik. Do not expose atuin-server directly to the internet on plain HTTP: the records are encrypted, but your credentials and session token are not protected by that. Also leave open_registration = false except during the minutes you are creating your own accounts.

Point the clients at it — this key is client-side, in config.toml:

sync_address = "https://atuin.example.com"

If your server sits behind an authenticating proxy such as Cloudflare Access, atuin can attach extra headers. Headers atuin sets itself (notably Authorization) cannot be overridden, and when this is set, cross-origin redirects are refused so the headers never leak to another origin:

extra_headers = { "CF-Access-Client-Id" = "...", "CF-Access-Client-Secret" = "..." }

Path C · No sync

Perfectly legitimate, and the correct choice on machines you do not own or on hosts where a stray credential in history would be a real problem. You keep everything from §4 through §9 — the database, the search, the filters, the stats. You lose cross-machine history and off-machine durability. If you go this route, back up ~/.local/share/atuin/ with whatever backs up your home directory.

Lab 11 · Rehearse the sync setup without an account
6 minutes · sandbox · no network calls

You should not register a throwaway account against a stranger's service just to practise. Instead, verify the local half of the model — the parts that exist before any server does.

atuin status

Expect:

Error: You are not logged in to a sync server - cannot show sync status

Location:
    crates/atuin/src/command/client/sync/status.rs:8:9

That error is the correct state for a local-only install, and recognising it saves you from filing a bug.

atuin store status
atuin config get sync_address -r
atuin config get sync_frequency -r
ls -la "$LAB/data"

Expect: a history store with an idx matching your row count; https://api.atuin.sh/; 5m; and a directory containing history.db, records.db, meta.db and key. That key file is the one thing in there you cannot regenerate.

  • You recognise the logged-out atuin status error as normal
  • store status showed a record index matching your history size
  • You located the key file and understand it is not derived from a password
  • You have decided which of the three paths you are taking, and why

When you do the real thing on your Mac, the order is: atuin import autoatuin registeratuin key → save it → then set up WSL.

Sources: Sync guide · Server setup · Docker · atuin default-config extra_headers comment

16 · The daemon: when a background process earns its keep

Without the daemon, every command you run spawns an atuin process twice — once to open the record, once to close it. That is fine until it isn't: a slow disk, a very large database, or a sync that decides to run mid-prompt all turn into latency you feel between pressing Enter and seeing output. The daemon exists to move that work off your prompt's critical path.

atuin daemon --help still labels it *Experimental* on 18.19.0. Take that at face value: it works, it is widely used, and it is not yet the thing the project promises will never change.

What it buys you

  • Writes become a socket message instead of a process spawn plus a SQLite transaction.
  • Sync moves off your prompt. daemon.sync_frequency (in seconds, default 300) is time-based rather than triggered by you typing.
  • It unlocks two features: daemon-fuzzy search mode (§6) and command-output capture with pty-proxy (§18). Neither works without it.
  • atuin history tail streams history events live as they arrive — a genuinely useful debugging window.

Enabling it

[daemon]
enabled = true
autostart = true            # start it on demand; incompatible with systemd_socket
sync_frequency = 300        # seconds
# socket_path = "~/.local/share/atuin/atuin.sock"
# pidfile_path  = "~/.local/share/atuin/atuin-daemon.pid"
# systemd_socket = false    # Linux only; not supported on macOS/Windows
# tcp_port = 8889           # non-unix platforms
atuin daemon status
Daemon running
  PID:      76102
  Version:  18.19.0
  Protocol: 1
  Healthy:  true
  Socket:   /tmp/atn-lab.sock
Verified failure: path must be shorter than SUN_LEN Unix domain socket paths have a hard length limit baked into the kernel struct — about 104 bytes on macOS, 108 on Linux. Atuin defaults the socket into data_dir, so if you relocate data_dir somewhere deep, the daemon dies at startup with:
Error: path must be shorter than SUN_LEN

Location:
    crates/atuin-daemon/src/server.rs:72:10
This is easy to miss because atuin daemon start was backgrounded and atuin daemon status just says Daemon is not running — the actual cause is only in the daemon's own output. Encountered live while writing this guide with a data directory nested under a long temporary path.

Fix: set socket_path explicitly to something short. It does not have to live beside your data.
[daemon]
socket_path = "/tmp/atuin.sock"
Diagnostic: run atuin daemon start --show-logs in the foreground. If the daemon will not start and status is unhelpful, this is the first thing to try.

WSL and systemd

On Linux the daemon can use systemd socket activation, which means systemd owns the socket and starts atuin on first connection. WSL2 only runs systemd when you have opted in:

# /etc/wsl.conf, then: wsl --shutdown from Windows
[boot]
systemd=true

If you have not done that, do not set systemd_socket = true — it is incompatible with autostart and will simply not work. The reliable WSL configuration is enabled = true, autostart = true, plain socket path. (Doc-derived plus the incompatibility noted in atuin default-config; not executed under WSL.)

Do you actually need it? On a modern laptop with a history under a few hundred thousand rows, the daemon is a marginal latency win. Turn it on when you have a concrete reason: you want daemon-fuzzy, you want output capture, you want time-based sync, or you can measure the prompt lag. "It sounds faster" is not a reason to run another background process.
Lab 12 · Run a daemon that cannot touch your real one
10 minutes · sandbox · starts and stops a background process

Your real daemon (if you have one) uses ~/.local/share/atuin/atuin.sock. This lab uses a different socket, so the two never meet.

export LAB3=~/atuin-lab3
rm -rf "$LAB3"; mkdir -p "$LAB3/config" "$LAB3/data"
cat > "$LAB3/config/config.toml" <<EOF
data_dir = "$LAB3/data"
db_path = "$LAB3/data/history.db"
key_path = "$LAB3/data/key"
session_path = "$LAB3/data/session"
auto_sync = false
update_check = false

[daemon]
enabled = true
socket_path = "/tmp/atuin-lab.sock"
pidfile_path = "$LAB3/data/atuin-daemon.pid"
EOF
export ATUIN_CONFIG_DIR="$LAB3/config"
export ATUIN_SESSION=$(atuin uuid)

atuin daemon status

Expect: Daemon is not running.

nohup atuin daemon start > "$LAB3/daemon.log" 2>&1 &
atuin daemon status

Expect: the five-line status block with Healthy: true and Socket: /tmp/atuin-lab.sock. If instead you get Daemon is not running, read $LAB3/daemon.log — that is where the SUN_LEN error hides.

Now record through the daemon and confirm it landed:

cd "$LAB3"
id=$(atuin history start -- "echo via-daemon")
atuin history end --exit 0 "$id"
atuin history list
2026-08-07 05:38:09	echo via-daemon	22ms

Deliberately reproduce the socket-length failure — this is the payoff:

atuin daemon stop
sed -i'' "s|/tmp/atuin-lab.sock|$LAB3/data/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.sock|" \
  "$LAB3/config/config.toml"
atuin daemon start --show-logs

Expect: Error: path must be shorter than SUN_LEN in the foreground. Now you will recognise it in the wild.

Clean up:

atuin daemon stop 2>/dev/null
rm -f /tmp/atuin-lab.sock
rm -rf "$LAB3"
export ATUIN_CONFIG_DIR="$LAB/config"   # back to the Lab 1 sandbox
  • atuin daemon status reported Healthy: true on your own socket
  • A command recorded through the daemon appeared in history list
  • You triggered and read the SUN_LEN error on purpose
  • The daemon is stopped and the socket removed

On Linux, sed -i'' should be sed -i. The BSD form above is for macOS.

Sources: daemon reference · systemd · atuin default-config [daemon] · measured on 18.19.0

17 · The other stores: dotfiles, scripts, and key-value

Once you have an encrypted, syncing, append-only record log, history is only the first thing you can put in it. Atuin ships three more stores that ride the same infrastructure: your shell aliases and variables, named runnable scripts, and a small key-value namespace. They sync with the same key, over the same protocol, to the same server.

One record log, four materialised stores records.db the source of truth append-only · encrypted per-host, monotonic idx atuin store status replay history history.db · §4 dotfiles aliases + variables scripts named, templated kv  namespaced pairs Consequences One key decrypts all four. One sync moves all four. atuin store rebuild <name> re-materialises any of them.
The stores are views. If one gets into a bad state you rebuild it from the log rather than repairing it in place.

Dotfiles: aliases and variables that follow you

Off by default. Turning it on is deliberate, because it means atuin starts writing into your shell environment.

atuin dotfiles alias set gs "git status"
Dotfiles are not enabled. Add

[dotfiles]
enabled = true

to your configuration file to enable them.

The default configuration file is located at ~/.config/atuin/config.toml.

A genuinely good error message — it tells you the fix. Enable and retry:

atuin config set dotfiles.enabled true

atuin dotfiles alias set gs "git status"
Aliasing 'gs=git status'.
atuin dotfiles alias set k "kubectl"
Aliasing 'k=kubectl'.
atuin dotfiles alias list
gs=git status
k=kubectl

Variables work the same way, with one asymmetry worth knowing:

atuin dotfiles var set EDITOR nvim
Setting 'export EDITOR=nvim'.

atuin dotfiles var set --no-export PROJECT api
Setting 'PROJECT=api'.

atuin dotfiles var list
export EDITOR=nvim
PROJECT=api
Verified: variables export by default; there is no --export atuin dotfiles var set FOO bar creates an exported variable — visible to child processes. To get a shell-local variable you pass --no-export. Passing --export is an error:
error: unexpected argument '--export' found

  tip: a similar argument exists: '--no-export'
This matters for anything sensitive: an exported variable ends up in the environment of every command you run, where ps e and /proc/PID/environ can see it. Use --no-export unless a child process genuinely needs the value.
Dotfiles versus a real dotfile manager Atuin's dotfiles store handles aliases and variables — not your .zshrc, not your editor config, not your ssh config. If you already use chezmoi, GNU Stow or a bare git repo, atuin does not replace it. What it adds is the thing those tools handle badly: aliases you invent mid-session and want on the other machine before you next sit down at it. Use both; they do not overlap.

Scripts: named, templated, runnable

atuin scripts new <NAME> [-d desc] [-t tag] [-s shebang] [--script FILE] [--last [N]] [--no-edit]
atuin scripts list | get <NAME> | run <NAME> [-v KEY=VALUE] | edit | delete
Verified trap: --script takes a file path, not script text The flag is undocumented in --help (its description is blank), and passing inline text fails with a message that names no file:
atuin scripts new deploy --script 'echo "deploying to {{ host }}"'
Error: No such file or directory (os error 2)

Location:
    crates/atuin/src/command/client/scripts.rs:266:34
The same command with a path works. Write the body to a file first, or omit --script and let it open $EDITOR.
printf 'echo "deploying to {{ host }}"\n' > /tmp/deploy.sh
atuin scripts new deploy --script /tmp/deploy.sh
atuin scripts list
Available scripts:
- deploy

Scripts carry metadata and template variables in {{ … }}:

atuin scripts get deploy
---
name: deploy
id: 351c0362-215b-4a6c-ba50-7314ea92e5f4
description: ""
tags: []
shebang:
script: |
  echo "deploying to {{ host }}"

atuin scripts run deploy -v host=prod-01
deploying to prod-01

Omit a variable and it prompts interactively:

atuin scripts run deploy
This script contains template variables that need values:
Enter value for 'host': 
Non-interactive use needs every variable supplied In a script or a CI job there is no one to answer the prompt. Measured with empty stdin, the prompt accepted nothing and the template rendered as deploying to — an empty string, silently. If you automate atuin scripts run, pass every -v explicitly and validate the output; a missing variable does not fail loudly.
The killer feature is --last atuin scripts new deploy --last 5 takes the last five commands from your history and opens them in your editor as a draft script. That is the natural workflow: do the thing manually, discover it works, then promote it. It is the cleanest answer atuin has to "I should have written that down."

The key-value store

A small namespaced dictionary that syncs. Useful for things that are per-you rather than per-repo: a current ticket number, a deploy target, a scratch value shared between machines.

atuin kv set --key deploy-target prod-01
atuin kv set --namespace work --key ticket ENG-4471

atuin kv get deploy-target
prod-01
atuin kv get --namespace work ticket
ENG-4471

atuin kv list
deploy-target
atuin kv list --all-namespaces
default.deploy-target
work.ticket
Verified: there is no --value flag The value is a positional argument. atuin kv set --key foo --value bar fails with error: unexpected argument '--value' found. The correct forms are atuin kv set --key foo bar, or omit the value entirely and it reads from stdin — which is how you store multi-line content:
cat notes.txt | atuin kv set --key notes
The KV store is encrypted, not access-controlled Values are encrypted with your atuin key like everything else, so the server cannot read them. But atuin kv get prints them in plaintext to anyone with your shell — no prompt, no unlock. It is not a password manager. Store identifiers, not credentials.
Lab 13 · Use all three stores
10 minutes · sandbox · nothing leaves your machine
# dotfiles
atuin config set dotfiles.enabled true
atuin dotfiles alias set gs "git status"
atuin dotfiles alias set kgp "kubectl get pods"
atuin dotfiles var set --no-export LAB_PROJECT api
atuin dotfiles alias list
atuin dotfiles var list

Expect: two aliases; one variable printed without a leading export.

# scripts — note the file path, per the trap above
printf 'echo "deploying {{ app }} to {{ host }}"\n' > "$LAB/deploy.sh"
atuin scripts new deploy -d "Deploy an app" -t ops --script "$LAB/deploy.sh"
atuin scripts list
atuin scripts get deploy
atuin scripts run deploy -v app=api -v host=prod-01

Expect: deploying api to prod-01. Then deliberately omit one and watch it prompt:

atuin scripts run deploy -v app=api

Expect: Enter value for 'host': — press Ctrl+C to abort.

# kv, including the stdin form
atuin kv set --key deploy-target prod-01
atuin kv set --namespace work --key ticket ENG-4471
printf 'line one\nline two\n' | atuin kv set --key notes
atuin kv get notes
atuin kv list --all-namespaces

Expect: the two-line value comes back intact, and the namespaced listing shows default.deploy-target, default.notes, work.ticket.

Finally, see all four stores in the record log:

atuin store status

Expect: multiple stores listed under your host — history plus entries for the stores you just wrote to. That is the §17 diagram made concrete.

  • Dotfiles refused to work until you enabled them, with a message that told you how
  • You hit the --script file-path requirement (or avoided it knowingly)
  • A template variable prompted when not supplied
  • atuin store status lists more than one store

Sources: Dotfiles · store reference · measured --help and live runs on 18.19.0

18 · pty-proxy and command output capture

Everything so far records what you ran. pty-proxy is atuin's answer to recording what came back. It works by inserting itself between your terminal emulator and your shell, which is a real architectural change and deserves to be understood before you enable it.

What it is

A lightweight PTY proxy — a process that owns the pseudo-terminal your shell runs in. Because everything your shell prints passes through it, it can see command output without replacing your terminal or your shell. It replaces the deprecated atuin hex and supports bash, zsh, fish and nu on Unix.

It uses OSC 133 prompt markers — escape sequences a shell emits to say "the prompt starts here", "the command starts here", "the output ended, exit code N". Atuin's init script already emits them; you can see the emitting functions in atuin init zsh as __atuin_osc133_command_executed and __atuin_osc133_command_finished. Those markers are how the proxy knows where one command's output stops and the next begins.

Two things it buys you

  • Overlay rendering. The search UI draws as an overlay rather than taking the full screen, so your scrollback survives closing it.
  • Output capture. Each command's output is held in memory keyed by its atuin history id — which is what the atuin_output MCP tool reads (§19).

Enabling it

# simplest: config.toml
[pty_proxy]
enabled = true
# or explicitly in your shell rc, which starts faster
eval "$(atuin pty-proxy init zsh)"
eval "$(atuin pty-proxy init bash)"
Two caveats that are easy to get wrong 1 · Output capture needs both pty-proxy and the daemon. Enabling the proxy alone captures nothing. If you want output, turn on §16 as well.

2 · The config form re-executes your shell config. With [pty_proxy] enabled = true, atuin init re-execs your shell inside the proxy — so everything sourced before the atuin init line runs twice. If your rc file does anything non-idempotent early (appending to PATH, starting an agent, printing a banner), you will see it double. Two fixes: move the atuin init line near the top of your config, or use the explicit atuin pty-proxy init form instead. Note this contradicts the ordering advice in §3 for the non-proxy case — the proxy is the documented exception.
Output lives in memory, not in the database Captured output is held in memory keyed by history id. It is not persisted to history.db, it is not synced, and it does not survive a daemon restart. This is a deliberate privacy choice — your command output frequently contains far more sensitive material than the commands themselves — but it means output capture is a recent-context feature, not an archive.
Should you turn it on? Only if you want the AI or MCP surface to be able to read what your commands printed (§19), or you specifically want overlay rendering. It is the most invasive thing in this guide — an extra process in your terminal's data path, plus a shell re-exec — for a benefit that is narrow unless you are using agents. Everything else in atuin works without it. This is not verified in this guide: it was not enabled on the machine used here, because doing so alters the interactive shell in a way a scripted session cannot honestly exercise.

Sources: pty-proxy reference · atuin default-config [pty_proxy] · OSC 133 functions in live atuin init zsh

19 · Atuin AI, the MCP server, and agent hooks

Atuin 18.x ships three separate AI-adjacent features that are easy to conflate. One sends data off your machine; two do not. Knowing which is which is the whole of the privacy story, so start there rather than with the feature list.

FeatureDirectionData leaves your machine?Needs an account?
Atuin AI (?, atuin ai)You ask a model for a commandYes — your prompt goes to the configured endpointYes, an Atuin Hub account (or your own endpoint)
MCP server (atuin mcp)A local agent queries your historyNo — stdio, on your machineNo
Agent hooks (atuin hook install)An agent's commands get recorded into your historyNoNo

Atuin AI

Bound to ? on an empty prompt by default (§5). You describe what you want; it proposes a command. Per the docs, it requires an Atuin Hub account and is currently free.

atuin ai init      # shell integration
atuin ai inline    # inline completion with a small TUI overlay

The [ai] config surface, with documented defaults:

KeyDefaultMeaning
ai.enabledfalseMaster switch. Also removable at init with --disable-ai
ai.modelunsetModel for new sessions
ai.endpointnullCustom or self-hosted backend
ai.api_tokennullToken for a custom endpoint
ai.endpoint_protocol"auto"auto · hub · oss
ai.session_continue_minutes60How long a conversation stays warm
ai.db_pathai_sessions.dbConversations stored in the atuin data dir
ai.yolofalseAuto-approves every permission check. See below
ai.capabilities.enable_history_searchtrueModel may search your history
ai.capabilities.enable_history_outputtrueModel may read captured command output (§18)
ai.capabilities.enable_file_toolstrueModel may read files
ai.capabilities.enable_command_executiontrueModel may run commands
ai.opening.send_cwdfalseInclude your directory in the opening context
ai.opening.send_last_commandfalseInclude your previous command
Read the capability defaults again Command execution, file reading, history search and output reading are all enabled by default once AI is on. The protection is the permission prompt — and ai.yolo = true removes it entirely, auto-approving everything. Do not set yolo on a machine with production credentials, and do not set it because a tutorial found the prompts annoying. If you want the assistant strictly advisory, turn the capabilities off:
[ai.capabilities]
enable_command_execution = false
enable_file_tools = false
Recommendation for a new atuin user: leave AI off for now It is the only feature in atuin that sends your data somewhere, it is bound to a key that used to mean something else, and it has nothing to do with the reason you installed a history manager. Add --disable-ai to your init line, get fluent with §5–§9, and revisit deliberately. If you want the AI plumbing without the outbound traffic, the MCP server below gives you exactly that.

The MCP server — the part worth having

atuin mcp starts a Model Context Protocol server over stdio that exposes your history to whatever agent you already use. Nothing is sent to Atuin; the agent runs locally and reads a local database.

# Claude Code
claude mcp add atuin -- atuin mcp
# generic MCP client config
{
  "mcpServers": {
    "atuin": {
      "command": "atuin",
      "args": ["mcp"]
    }
  }
}

Two read-only tools are exposed:

  • atuin_history — fuzzy search over your history, returning commands with timestamp, exit code, duration and location. Filterable by scope (global, host, directory, workspace, session), by failures only, and by author/agent.
  • atuin_output — fetches captured terminal output for a history id. Requires the daemon and pty-proxy (§18); without them it has nothing to return.
Read-only is a property of the tools, not of your history Neither tool writes. But an agent that can read your history can read every command you have ever run on every synced machine — hostnames, internal domains, database names, file paths, and whatever secrets slipped past the filter (§12). Wire this up only for agents you would already trust with your shell, and tighten history_filter first.

Agent hooks

atuin hook install registers atuin as a hook handler inside a coding agent, so commands the agent runs land in your history tagged with the agent as author. It writes to the agent's own config: ~/.claude/settings.json for Claude Code, ~/.codex/hooks.json for Codex, ~/.pi/agent/extensions/atuin.ts for pi. Recognised agent names include claude-code, codex, copilot, opencode and pi.

The recording uses the same two-phase lifecycle as your shell: the agent's pre-tool hook opens the record, the post-tool hook closes it with an exit code — exactly the history start / history end pair from §4.

Then the author column becomes a filter:

# everything an agent ran
atuin search --author '$all-agent' -- ''

# everything a human ran, plus one specific agent
atuin search --author '$all-user' --author 'claude-code' -- ''

$all-user means any author that is not a known agent; $all-agent means any known agent. These two literals also show up inside the generated zsh init, where the autosuggestion strategy uses --author '$all-user' so an agent's commands never become your inline suggestion — a small, well-judged detail.

Why agent hooks are worth it even if you skip everything else in §19 The commands an agent runs in your repo are exactly the ones you will want to find next week and cannot remember, because you did not type them. Capturing them costs nothing, sends nothing anywhere, and makes --author a genuinely useful axis alongside directory and exit code.

Sources: AI introduction · AI settings · MCP server · Agent hooks · measured atuin init zsh

20 · Theming and the shape of the UI

Two independent systems control how atuin looks: [ui] decides what information the result list shows, and [theme] decides what colour it is. The first one changes how useful the tool is; the second one changes how it feels. Spend your time on the first.

Columns — the setting that actually matters

Covered in §5, repeated here because it belongs in the reference: columns defaults to ["duration", "time", "command"]. Types and default widths: duration (5), time (8, relative), datetime (16, absolute), directory (20), host (15), user (10), exit (3), command (expands).

[ui]
# objects let you set width and which column absorbs slack
columns = ["duration", { type = "directory", width = 30 }, "command"]

# exactly one column should have expand = true
columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }]

syntax_highlight = true   # uses the theme's colours; unavailable on some platforms

Themes

Built-ins: default, autumn, marine, and none (no styling at all — useful in a terminal with an unusual palette, or for screenshots).

[theme]
name = "autumn"
# debug = true    # extra output from the theme manager while you iterate

Custom themes are TOML files in ~/.config/atuin/themes/, or wherever ATUIN_THEME_DIR points. A file named NAME.toml is selected as name = "NAME".

# ~/.config/atuin/themes/lowkey.toml
[theme]
name = "lowkey"
parent = "autumn"

[colors]
AlertInfo = "green"
Guidance = "#888844"

Recognised colour meanings: AlertInfo, AlertWarn, AlertError, Annotation, Base, Guidance, Important, Title, Muted, and the syntax roles SyntaxCommand, SyntaxFlag, SyntaxString, SyntaxVariable, SyntaxOperator, SyntaxComment. Values accept named colours (teal), hex (#ff0088), ANSI (@ansi_(255)) and RGB (@rgb_(255, 128, 0)). Anything you leave undefined inherits from parent, or from the default theme.

Two more surfaces worth knowing

[tmux]
enabled = true      # search in a tmux popup; needs tmux >= 3.2
width = "80%"
height = "60%"

[keys]
scroll_exits = true            # up/down past the ends leaves the TUI
exit_past_line_start = true    # left arrow at column 0 exits
accept_past_line_end = true    # right arrow at end acts like Tab
accept_past_line_start = false
accept_with_backspace = false

The tmux popup is supported in zsh, bash and fish, and is documented not to work with iTerm's native tmux integration. It can also be toggled per-shell with the ATUIN_TMUX_POPUP environment variable, which atuin init zsh exports as false on its very first line.

Accessibility settings hiding in plain sight prefers_reduced_motion = true (or NO_MOTION=true in the environment) stops live-updating timers and other animation. theme.name = "none" removes colour entirely, which is the right answer if you rely on your terminal's own high-contrast palette. Neither is advertised as an accessibility feature, but both are.

Sources: Theming · atuin default-config [ui], [theme], [tmux], [keys] on 18.19.0

21 · The SQL escape hatch

Every claim in §1 about atuin being a database cashes out here. The CLI covers the common questions; SQL covers the ones nobody anticipated. This is also where you verify atuin's own output rather than trusting it — as §9 demonstrated.

Read-only, and use the right binary Query freely; do not write. Atuin treats history.db as a materialised view of records.db (§17), so a manual UPDATE is at best temporary and at worst leaves the two out of sync. If you need to change history, use atuin search --delete or prune.

On macOS, check which sqlite3 you are running — a stale one on PATH can lack functions you expect. sqlite3 --version; /usr/bin/sqlite3 is the system one. Atuin itself reported "sqlite_version": "3.46.0" via atuin doctor, which is the version compiled into the binary, not the one on your PATH.

The two unit conversions you will need constantly

-- timestamp is NANOSECONDS since epoch
datetime(timestamp/1000000000, 'unixepoch', 'localtime')

-- duration is NANOSECONDS
round(duration/1e9, 2) as secs

And one predicate you should put in every query: where deleted_at is null. Without it you are counting tombstones (§13).

Cookbook

When do you actually work?

sqlite3 -header -column ~/.local/share/atuin/history.db "
select strftime('%H', timestamp/1000000000, 'unixepoch', 'localtime') hour,
       count(*) n
from history where deleted_at is null
group by hour order by n desc limit 5;"
hour  n
----  --
05    26

The lab history was seeded in a single minute, so it has one bucket. Against a real database this produces the histogram of your working day.

Which programs fail most?

sqlite3 -header -column "$LAB/data/history.db" "
select substr(command, 1, instr(command || ' ', ' ') - 1) prog,
       count(*) runs,
       sum(exit != 0) fails
from history
where deleted_at is null and exit >= 0
group by prog having runs > 1
order by fails desc, runs desc limit 8;"
prog     runs  fails
-------  ----  -----
git      6     1
cargo    3     1
npm      3     1
docker   2     0
kubectl  2     0
ssh      2     0

Note exit >= 0: it excludes imported rows, whose exit is -1 (§10). Leave it out and every import counts as a non-failure, which quietly deflates your rates.

What takes the longest?

sqlite3 -header -column "$LAB/data/history.db" "
select round(duration/1e9, 2) secs, substr(command, 1, 60) cmd
from history where deleted_at is null
order by duration desc limit 10;"

In the lab every duration is ~0.03s, because the lab measures two process spawns rather than real work (§4). Against your real history this is the query that finds the builds worth caching.

Where do you spend your time?

sqlite3 -header -column "$LAB/data/history.db" "
select cwd, count(*) n
from history where deleted_at is null
group by cwd order by n desc limit 15;"

The stats audit from §9

sqlite3 "$LAB/data/history.db" "
select count(*) total,
       count(distinct command) distinct_command,
       count(distinct command || '|' || cwd) distinct_command_cwd
from history;"
26|24|26

Live versus tombstoned

sqlite3 -header -column "$LAB/data/history.db" "
select count(*) total,
       sum(deleted_at is null) alive,
       sum(deleted_at is not null) tombstoned
from history;"

Commands you run in exactly one place

sqlite3 -header -column "$LAB/data/history.db" "
select command, count(distinct cwd) dirs, count(*) runs
from history where deleted_at is null
group by command having dirs = 1 and runs > 3
order by runs desc limit 10;"

A project-local alias candidate: something you type often, always in one directory.

Per-machine breakdown, after sync

sqlite3 -header -column ~/.local/share/atuin/history.db "
select hostname, count(*) n,
       datetime(min(timestamp)/1000000000, 'unixepoch', 'localtime') first,
       datetime(max(timestamp)/1000000000, 'unixepoch', 'localtime') last
from history where deleted_at is null
group by hostname order by n desc;"

Remember hostname is the composite host:user (§4), so the same machine under two accounts appears twice.

Human versus agent

sqlite3 -header -column ~/.local/share/atuin/history.db "
select coalesce(nullif(author,''), '(none)') author, count(*) n
from history where deleted_at is null
group by author order by n desc;"

Only interesting once agent hooks are installed (§19).

The schema is not a public API Atuin has changed this table before — author, intent and shell are visibly bolted onto the end of the CREATE TABLE by migration, and _sqlx_migrations is right there in the same file. Queries you write today can break on upgrade. Keep them in a file you can fix, do not build anything load-bearing on them, and re-check after a major version bump.
Lab 14 · Ask your real history a question the CLI cannot
10 minutes · read-only · your real database

Open a new terminal (no sandbox) so you are querying real data. Every query here is read-only.

DB=~/.local/share/atuin/history.db
sqlite3 "$DB" 'select count(*) from history where deleted_at is null;'

Expect: your live row count. Compare it with atuin stats | tail -2 — if Total commands differs, you now know why (§9).

# your actual working hours
sqlite3 -header -column "$DB" "
select strftime('%H', timestamp/1000000000,'unixepoch','localtime') hour, count(*) n
from history where deleted_at is null group by hour order by hour;"

# the commands you should have aliased
sqlite3 -header -column "$DB" "
select command, count(*) n from history
where deleted_at is null and length(command) > 12
group by command order by n desc limit 15;"
  • You reconciled your SQL row count against atuin stats
  • You produced an hour-of-day histogram of your own shell use
  • You found at least one long command you type repeatedly
  • You wrote no data

Sources: live sqlite3 .schema and measured queries on 18.19.0 · store reference (for why history.db is derived)

22 · Decision matrices

Everywhere atuin offers a choice, collected in one place with a recommendation attached. The recommendations assume your situation: new to atuin, macOS plus Ubuntu under WSL, wanting the whole surface.

Search mode

If you…UseBecause
remember the first wordprefixZero noise. Ideal on the up-arrow binding
remember a distinctive substringfulltextLiteral, predictable. Best for hostnames and filenames
remember the shape, not the spellingfuzzy recommended defaultMost forgiving; use ' to tighten it when it goes wide
have a huge history and run the daemondaemon-fuzzyScored by an in-memory index. No benefit without the daemon

Filter mode

QuestionMode
"I ran it somewhere, sometime"global recommended default
"I ran it on this machine, not the other one"host
"What have I done in this window?"session
"What do I run in this project?"workspace turn ondirectory breaks one cd down
"Recent context first, but keep everything reachable"session-preload

Sync

SituationChoice
Two personal machines you both use interactivelyHosted — contents are encrypted before they leave
A policy that forbids third-party storage even of ciphertextSelf-host behind a reverse proxy with TLS
You already run a homelab with backupsSelf-host; the marginal cost is genuinely low
Shared, borrowed, or production machinesNo sync. Consider not installing atuin at all there
One machine, everNo sync — but back up ~/.local/share/atuin/

Daemon

You want…Daemon?
Lower prompt latency on a very large historyYes — but measure first
daemon-fuzzy searchRequired
Command output capture for agentsRequired, plus pty-proxy
Time-based sync regardless of activityYes
Fewest moving partsNo. This is the right default for a new user

atuin versus the alternatives

Plain HISTFILEfzf + Ctrl-Ratuin
StorageText file, cappedSame text fileSQLite, uncapped
Concurrent shellsLast writer winsSame problemCorrect
Fuzzy searchNoYes, excellentYes, same operators
Directory contextNoNoYes
Exit codesNoNoYes
Cross-machineNoNoYes, E2E encrypted
Setup costNoneMinutesMinutes, plus a config you will tune
Failure modeSilent data lossInherits the file's problemsA database and a background process to understand

They are not exclusive. fzf remains the better general-purpose fuzzy finder for files, branches and processes; atuin is specifically better at history because it stores more about history. A common good setup is atuin on Ctrl+R and fzf everywhere else — atuin init zsh --disable-ctrl-r exists precisely for people who want it the other way round.

Keybinding posture

Covered in full in §5. Short version: full takeover, plus --disable-ai until you have deliberately decided you want ? bound to a language model.

23 · Translation layer: old instinct → atuin move

You already know how to use shell history. Most of that knowledge transfers; some of it actively misleads. This table is the fastest route from what your fingers do now to what they should do instead — and, more usefully, why the new move is better rather than merely different.

Your instinctThe atuin moveWhy it is better
Ctrl+R, then Ctrl+R again to step back Ctrl+R, then keep typing to narrow You filter instead of stepping. Repeating Ctrl+R now cycles filter mode — a different and more useful axis (§7)
four times to find the command from a minute ago Type the first few characters, then Prefix-seeded search beats counting. Set search_mode_shell_up_key_binding = "prefix" and this is exact (§6)
history | grep docker atuin search --cmd-only "'docker" No pipe, no cap, and you can add --exclude-exit 0, --cwd, --after (§8)
!! and !$ Still work — these are shell features, untouched Nothing to relearn. Atuin does not replace history expansion
fc -l to list recent commands atuin history list, or atuin search --limit 20 Gets you --format with directory, exit and host (§8)
Raising HISTSIZE / SAVEHIST to avoid losing history Do nothing There is no cap. The relevant setting no longer exists for atuin's copy
setopt SHARE_HISTORY so terminals see each other Do nothing Every command is committed immediately; all sessions read the same table (§1)
Editing ~/.zsh_history to remove a secret atuin search --delete "'thesecret", then fix the real file too Two stores now hold it. Add a history_filter so it cannot recur (§12)
A leading space to hide one command Same reflex, unchanged Honoured by atuin. Still the fastest one-off exclusion (§12)
"Which of these two similar commands worked?" atuin search --exit 0 "'kubectl" Impossible with a text file. This is the feature you did not know to want (§8)
scping .zsh_history between machines atuin sync, or a one-time HISTFILE=… atuin import zsh Converges continuously instead of clobbering (§14, §10)
"What did I do in this repo last week?" Workspace filter mode, or --cwd with --after "1 week ago" The directory was always the missing column (§7)
alias gs='git status' in your rc file atuin dotfiles alias set gs "git status" Syncs to your other machine without a dotfile commit (§17). Keep the rc file for anything structural
Copy-pasting a five-command sequence into a gist atuin scripts new deploy --last 5 Pulls straight from history into an editable, templated, syncing script (§17)
? at the prompt to type a question mark Beware — it opens the AI assistant Not better, just different, and it will startle you. --disable-ai restores the old behaviour (§5)
The one habit that generalises Stop asking "what did I type?" and start asking "what am I filtering on?" Every atuin feature is a column: directory, exit code, host, session, author, time. Once the question has a column in it, the answer is one flag away.

24 · Capstone: outfit your Mac and your WSL box for real

Everything so far ran in a sandbox. This is the cumulative exercise that leaves you with a working, configured, synced setup on both machines — and a written record of the decisions you made. Budget an hour, and do the phases in order; each depends on the one before.

This one touches your real configuration Unlike Labs 1–14, the capstone modifies ~/.config/atuin/config.toml and your shell rc files. Back up first: cp ~/.zshrc ~/.zshrc.bak and, if it exists, cp ~/.config/atuin/config.toml ~/.config/atuin/config.toml.bak. Nothing here is destructive to history, but a broken rc file is an annoying way to end an evening.
1

Install and verify on the Mac

brew install atuin              # or upgrade
atuin --version
echo 'eval "$(atuin init zsh --disable-ai)"' >> ~/.zshrc
exec $SHELL
atuin doctor | head -25

Checkpoint: shell.plugins contains "atuin" and shell.preexec is "built-in". Run three commands, then atuin history list | tail -3 and see them.

2

Import before anything else

wc -l ~/.zsh_history
atuin import auto
sqlite3 ~/.local/share/atuin/history.db 'select count(*) from history;'

Checkpoint: the row count is in the same ballpark as the line count. If your zsh history lacks EXTENDED_HISTORY, expect every imported row to share roughly the same timestamp (§10) — note it now so it does not confuse you later.

3

Write a config you understand

Do not paste this blindly — read each line and change what you disagree with. Top-level keys first (§11).

# ~/.config/atuin/config.toml
search_mode = "fuzzy"
search_mode_shell_up_key_binding = "prefix"
filter_mode = "global"
workspaces = true
enter_accept = false
secrets_filter = true
store_failed = true
update_check = true

history_filter = [
  "^ *$",
  "--password",
  "^export .*(TOKEN|SECRET|KEY|PASSWORD)=",
]

cwd_filter = []

[search]
filters = ["global", "workspace", "session"]

[ui]
columns = ["duration", "time", "host", "command"]

[dotfiles]
enabled = true
atuin config print | head -12
atuin config get enter_accept -v
atuin history prune --dry-run

Checkpoint: every top-level key appears above the first [ in config print. The dry run shows exactly what your new filters would remove — read the whole list before deciding whether to run it for real.

4

Decide on sync, then execute the decision

Use the matrix in §15. For the hosted path:

atuin register -u <username> -e <email>
atuin key                # → password manager, immediately
atuin sync
atuin status

Checkpoint: atuin status reports rather than errors, and the key is stored somewhere you will still have it in three years. Do not proceed to step 5 until the key is saved.

5

Bring up the WSL box

# in Ubuntu under WSL
curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh
echo 'eval "$(atuin init bash --disable-ai)"' >> ~/.bashrc

# WSL trap: make sure a login shell reaches .bashrc
grep -q bashrc ~/.bash_profile 2>/dev/null || echo '[ -f ~/.bashrc ] && . ~/.bashrc' >> ~/.bash_profile

exec $SHELL
echo "$ATUIN_SESSION"          # must be non-empty
atuin import auto              # local bash history FIRST
atuin login -u <username>     # password, then the key
atuin sync -f
atuin store verify

Checkpoint: $ATUIN_SESSION is non-empty (the init ran), store verify passes, and a command you ran on the Mac shows up here. Confirm with SQL:

sqlite3 -header -column ~/.local/share/atuin/history.db "
select hostname, count(*) n from history
where deleted_at is null group by hostname;"

Expect: two rows — one per machine.

6

Prove the round trip

# on the Mac
echo "capstone-marker-mac-$(date +%s)"
atuin sync

# on WSL
atuin sync
atuin search --cmd-only "'capstone-marker"

Checkpoint: the marker appears. Now do it in the other direction. If both work, sync is real rather than theoretical.

7

Make it yours

# find your own alias candidates
atuin stats -n 2 -c 20

# create two, and watch them cross the sync
atuin dotfiles alias set <short> "<the long thing>"
atuin sync

# promote a real sequence into a script
atuin scripts new <name> --last 5

Checkpoint: the alias exists on the other machine after a sync there. You have used the record log for something other than history.

8

Write down what you chose

Not optional, and the reason is practical: in six months you will wonder why ? behaves oddly or why some commands are missing, and the config alone will not tell you. Add a comment block at the top of your config.toml:

# atuin config — set up 2026-08-07, atuin 18.19.0
# Sync: hosted api.atuin.sh. Key in 1Password under "atuin encryption key".
# AI disabled at init (--disable-ai) — ? stays a question mark.
# enter_accept = false deliberately; Tab is the accept key here.
# workspaces = true because directory mode breaks one cd down.
# Machines: MacBook (zsh), wsl-ubuntu (bash).

Mastery rubric

CapabilityWeakStrong
SearchTypes a query and scrollsReaches for a filter or a flag before scrolling
ModesCycles until it looks rightNames the mode they want and why
ConfigPastes snippetsChecks config print ordering and config get -v after every change
ExclusionDeletes after the factAdds a filter, then prunes once
Sync"It syncs"Explains key vs password, and where the key is backed up
DebuggingReinstallsRuns atuin doctor, reads shell.plugins, checks the daemon log
Beyond historyOnly uses Ctrl+RHas at least one alias or script in the store, and has queried the DB directly

Where to go next

Self-hosting

Stand up atuin-server behind Caddy with automatic TLS and a Postgres backend; move sync_address across and re-verify. Prerequisites: reverse proxies, TLS, backups. (§15)

Agent integration

Install agent hooks and the MCP server, then use --author as a first-class filter. Add pty-proxy plus the daemon if you want atuin_output to work. (§19, §18)

Analytics

Build a small script over §21's queries that reports weekly: failure rates by program, slowest builds, directories you have abandoned. Pin it to a schedule.

25 · Troubleshooting: symptom → cause → fix

SymptomLikely causeFix
Nothing is recorded; atuin history list stays empty The init line never ran atuin doctor → is "atuin" in shell.plugins? If not, the eval is missing, in the wrong file, or after an early return. On WSL check that ~/.bash_profile sources ~/.bashrc
$ATUIN_SESSION is empty in a new terminal Same as above — the init defines it The fastest single diagnostic there is. Empty means the hook chain is broken
Ctrl+R opens something else A framework or plugin bound it after atuin Move the atuin eval to the end of your rc file. Confirm with bindkey | grep '\^R' in zsh
A config change did nothing The key landed inside a [table] atuin config print and check it is above the first [. See the callout in §11
atuin config get X says (not set in config file) but behaviour differs from the docs You are reading the file value, not the resolved one atuin config get X -v shows both. The resolved value is what runs
Search returns commands that obviously do not match Fuzzy subsequence matching Prefix the term with ', or switch to fulltext (§6)
Directory filter mode returns nothing You are one cd below where the commands ran Set workspaces = true and use workspace mode (§7)
A command ran that you did not intend enter_accept = true and Enter executes on selection Set it false, and use Tab to accept (§5)
? opens a chat UI instead of typing a question mark The AI widget is bound on an empty prompt atuin init zsh --disable-ai, or ai.enabled = false (§19)
atuin status errors with "not logged in" No sync account — normal for local-only Nothing to fix. atuin register only if you want sync (§14)
Daemon will not start; status says "not running" Socket path exceeds SUN_LEN (~104 bytes) atuin daemon start --show-logs to see the real error, then set a short daemon.socket_path such as /tmp/atuin.sock (§16)
atuin scripts new --script '…'No such file or directory (os error 2) --script takes a file path Write the body to a file first, or omit the flag and use $EDITOR (§17)
atuin kv set --key k --value vunexpected argument '--value' The value is positional atuin kv set --key k v, or pipe it on stdin (§17)
atuin dotfiles … → "Dotfiles are not enabled" Off by default atuin config set dotfiles.enabled true
atuin history dedup → usage error --before and --dupkeep are both required atuin history dedup --dry-run --before "now" --dupkeep 1 (§13)
Imported history all has the same timestamp Source file had no timestamps (bash, or zsh without EXTENDED_HISTORY) Nothing recoverable — they were never recorded. Turn on setopt EXTENDED_HISTORY so it does not recur (§10)
Imported rows show exit=-1, cwd=unknown Working as designed — the source file had neither Expected. Exclude them from failure-rate queries with exit >= 0 (§21)
wc -l over --cmd-only disagrees with the row count Multi-line commands span multiple output lines Use --print0 with read -r -d '' (§8)
Second machine syncs but shows no commands Wrong encryption key — records arrive undecryptable atuin store verify. Re-run atuin login with the key from atuin key on machine one (§14)
Second machine looks incomplete Partial sync state atuin sync -f to force a full re-download
atuin info shows a config path you are not using Known display bug — it prints the default, not $ATUIN_CONFIG_DIR Trust the Env Vars block and atuin config print (§3)
atuin wrapped says your history is empty It defaults to last year atuin wrapped 2026 (§9)
Everything in your rc file runs twice [pty_proxy] enabled = true re-execs the shell Move the atuin init near the top, or use eval "$(atuin pty-proxy init zsh)" explicitly (§18)
Docs and behaviour disagree Doc drift — confirmed for sync_frequency atuin config get KEY -r is the arbiter. The binary wins (§11)
The three-command triage Before investigating anything else: atuin doctor (did the init run?), atuin config print (is my config where I think it is?), echo $ATUIN_SESSION (are the hooks live in this shell?). Those three answer most reports.

26 · Cheat sheet

The universal starting pattern

# install → wire → import → verify
brew install atuin                                  # macOS
curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh   # Linux/WSL
echo 'eval "$(atuin init zsh --disable-ai)"' >> ~/.zshrc
exec $SHELL
atuin import auto
atuin doctor | head -25

Search

atuin search -i                    # the TUI
atuin search --cmd-only "'git"     # exact-ish
atuin search --exclude-exit 0      # failures only
atuin search --exit 0 "'kubectl"   # the one that worked
atuin search --cwd ~/proj          # by directory
atuin search --after "yesterday"
atuin search --limit 20 --reverse
atuin search --print0              # safe for pipes
atuin search --format "{time} {exit} {directory} {command}"

Fuzzy operators

'term      exact substring
^term      anchored to start
term$      anchored to end
!term      exclude
a | b      either
a b        both (AND)

Space-separated terms are ANDed. Lead with ' whenever you know the word.

Inside the TUI

Ctrl-R     cycle FILTER mode
Ctrl-S     cycle SEARCH mode
Tab        accept for editing (safe)
Enter      accept (may execute!)
Ctrl-O     inspector
Ctrl-A D   delete entry
Ctrl-A C   switch to its context
Ctrl-Y     copy
Esc        cancel

History management

atuin history list
atuin history last
atuin history tail                 # needs daemon
atuin history prune --dry-run
atuin history prune
atuin history dedup --dry-run \
  --before "now" --dupkeep 1
atuin search --delete "'query"
atuin search --delete-it-all       # DANGER

Config

atuin config print
atuin config get KEY -v
atuin config set KEY VALUE
atuin default-config
atuin info
atuin doctor

Top-level keys before any [table].

Sync

atuin register -u USER -e EMAIL
atuin login -u USER
atuin key                          # back this up
atuin sync
atuin sync -f                      # full re-download
atuin status
atuin store status
atuin store verify
atuin account change-password
atuin account delete               # DANGER

Stores

atuin dotfiles alias set gs "git status"
atuin dotfiles alias list
atuin dotfiles var set --no-export K v
atuin scripts new NAME --last 5
atuin scripts run NAME -v k=v
atuin kv set --key k value
atuin kv get k
atuin kv list --all-namespaces

--script wants a file path. kv has no --value.

Daemon & extras

atuin daemon start --show-logs
atuin daemon status
atuin daemon stop
atuin stats -n 2 -c 20
atuin wrapped 2026
atuin mcp
atuin hook install
atuin uuid

Config quick reference

KeyDefaultCommon setting
search_modefuzzyfuzzy
search_mode_shell_up_key_binding= search_modeprefix
filter_modeglobalglobal
workspacesfalsetrue
enter_acceptfalse built-in / true in templateSet it explicitly
sync_frequency5m0 for two machines
secrets_filtertrueleave on
store_failedtrueleave on
[search] filtersall six["global","workspace","session"]
[ui] columns["duration","time","command"]add "host" when syncing
[dotfiles] enabledfalsetrue
[daemon] enabledfalseonly with a reason
ai.enabledfalse--disable-ai at init too

Paths

WhatWhere
Client config~/.config/atuin/config.toml · override the dir with ATUIN_CONFIG_DIR
Server config~/.config/atuin/server.toml
Themes~/.config/atuin/themes/ · or ATUIN_THEME_DIR
History database~/.local/share/atuin/history.db
Record log~/.local/share/atuin/records.db
Encryption key~/.local/share/atuin/keyback this up
Session token~/.local/share/atuin/session
Daemon socket~/.local/share/atuin/atuin.sock — keep the path short

Glossary

agent hook
An integration installed by atuin hook install that makes an AI coding agent record the commands it runs into your atuin history, tagged with the agent as author. (§19)
author
A history column naming who ran a command: your username, or an agent name such as claude-code. Filterable with --author, including the literals $all-user and $all-agent. (§4, §19)
bash-preexec
A third-party shell library that gives bash the preexec/precmd hooks zsh has natively. Atuin bundles a copy inside atuin init bash; suppress it with ATUIN_NO_BUILTIN_PREEXEC=1. (§3)
cwd_filter
A config list of regexes matched against the working directory. Commands run in a matching directory are never recorded. (§12)
daemon
An optional long-lived background process that accepts history writes over a unix socket, syncs on a timer, and enables daemon-fuzzy search and output capture. Marked experimental in 18.19.0. (§16)
daemon-fuzzy
A search mode that scores fuzzy matches using an in-memory index held by the daemon. Behaves as plain fuzzy for non-interactive searches. (§6)
dotfiles store
Atuin's synced store of shell aliases and variables, managed with atuin dotfiles. Disabled by default. Not a general dotfile manager. (§17)
end-to-end encryption
Records are encrypted on your machine with a local key before being sent to the sync server, which stores ciphertext it cannot read. The key is independent of your account password. (§14)
enter_accept
Config option deciding whether Enter in the search UI runs the selected command immediately or places it on the prompt. Built-in default false; the shipped template sets true. (§5)
exit code
The status a command returned, stored per row. -1 means unknown — every imported row has it. (§4, §10)
filter mode
The scope a search runs against: global, host, session, directory, workspace or session-preload. Cycled with Ctrl+R inside the TUI. (§7)
fuzzy matching
Subsequence matching: your characters must appear in order but need not be adjacent. Powerful and noisy; tightened with the ' operator. (§6)
history.db
The queryable SQLite database of history rows. A materialised view derived from records.db, rebuildable with atuin store rebuild history. (§4, §17)
history_filter
A config list of regexes matched against command text. Matching commands are never recorded. Unanchored unless you write ^ and $. (§12)
hostname
Stored as the composite host:user, e.g. macbook.local:you. The same machine under two accounts appears as two hostnames. (§4)
key (encryption key)
The local secret at ~/.local/share/atuin/key that encrypts and decrypts your records. Printed by atuin key. Not derived from your password and not recoverable if lost. (§14)
KV store
A namespaced key-value store that syncs with your history. Values are encrypted at rest and in transit but printed in plaintext by atuin kv get. (§17)
MCP server
atuin mcp — a Model Context Protocol server over stdio exposing two read-only tools, atuin_history and atuin_output, to local AI agents. Sends nothing off-machine. (§19)
n-gram (stats)
The -n option to atuin stats, grouping by runs of consecutive words rather than single commands. The practical way to find aliases you should have written. (§9)
OSC 133
Terminal escape sequences marking prompt and command boundaries. Atuin's init emits them; pty-proxy reads them to know where one command's output ends. (§18)
precmd / preexec
Shell hooks running before the prompt is drawn and before a command executes. Atuin attaches history end and history start to them. (§4)
prune
atuin history prune — retroactively deletes rows matching your current history_filter and cwd_filter. Always preview with --dry-run. (§12)
pty-proxy
An experimental process sitting between your terminal and shell, enabling overlay rendering and in-memory command-output capture. Output capture also requires the daemon. (§18)
records.db
The append-only, encrypted log that is atuin's actual source of truth. All stores — history, dotfiles, scripts, kv — are materialised from it, and it is what sync transfers. (§17)
scripts store
Named, tagged, template-parameterised scripts stored and synced by atuin. Created with atuin scripts new; --script expects a file path. (§17)
search mode
How a query is matched: prefix, fulltext, fuzzy or daemon-fuzzy. Cycled with Ctrl+S. (§6)
secrets_filter
A built-in refusal to record commands matching known credential shapes — AWS key ids, GitHub PATs, Slack tokens and webhooks, Stripe keys. On by default. A seatbelt, not a vault. (§12)
session
A UUID identifying one shell session, exported as $ATUIN_SESSION by the init script. An empty value means atuin's hooks never loaded. (§4, §25)
SUN_LEN
The kernel limit on unix domain socket path length — about 104 bytes on macOS. Exceeding it makes the atuin daemon fail at startup with path must be shorter than SUN_LEN. (§16)
tombstone
A deleted row, marked with deleted_at rather than removed. Tombstones are how deletions propagate across synced machines — and why mass deletion means mass syncing. (§13)
workspace
A filter mode scoping results to the enclosing git repository rather than the exact directory. Requires workspaces = true. (§7)

Index

A
  • --after§8
  • agent hooks → §19
  • atuin account delete§13, §14
  • atuin ai§19
  • ai.yolo§19
  • --author, $all-agent, $all-user§19
  • ATUIN_CONFIG_DIR§3, §11
  • ATUIN_HISTORY_ID§4
  • ATUIN_NO_BUILTIN_PREEXEC§3
  • ATUIN_SESSION§4, §25
  • ATUIN_THEME_DIR§20
  • ATUIN_TMUX_POPUP§20
  • auto_sync§11
B
  • bash 3.1 requirement → §3
  • bash-preexec → §3
  • --before§8, §13
C
  • --cmd-only (and its trap) → §8
  • columns§5, §20
  • command_chaining§11
  • common_subcommands§9
  • atuin config get -r / -v§11
  • atuin config print§11
  • Ctrl-A D (delete) → §13
  • Ctrl-O (inspector) → §5
  • Ctrl-R (cycles filter) → §5, §7
  • Ctrl-S (cycles search) → §5, §6
  • cwd column → §4
  • cwd_filter§12
D
  • daemon → §16
  • daemon-fuzzy§6
  • atuin default-config§11
  • --delete, --delete-it-all§13
  • deleted_at§4, §13
  • atuin history dedup§13
  • directory filter mode → §7
  • --disable-ai§5, §19
  • --disable-ctrl-r, --disable-up-arrow§5
  • atuin doctor§3, §25
  • atuin dotfiles§17
  • --dry-run§12, §13
  • --dupkeep§13
  • duration (nanoseconds) → §4, §21
E
  • enter_accept§5, §11
  • --exclude-cwd§7
  • --exclude-exit§8
  • exit column, -1§4, §10
  • EXTENDED_HISTORY§10
  • extra_headers§15
F
  • filter modes → §7
  • [search] filters§7
  • --format§8
  • fulltext mode → §6
  • fuzzy operators ' ^ $ ! |§6
  • fzf comparison → §22
G
  • global filter mode → §7
H
  • HISTCONTROL=ignorespace§12
  • HISTFILE (for import) → §10
  • HIST_IGNORE_SPACE§12
  • atuin history start/end§4
  • atuin history tail§16
  • history_filter§12
  • history.db§4, §21
  • atuin hook install§19
  • hostname (host:user) → §4
  • host filter mode → §7
I
  • atuin import§10
  • --include-duplicates§8
  • atuin info (and its bug) → §3
  • atuin init§3, §5
  • inline_height§11
  • intent column → §4
K
  • [keys] section → §20
  • keymap_mode§11
  • atuin key§14
  • atuin kv§17
L
  • leading space → §12
  • --limit, --offset§8
  • atuin history list§8
  • atuin login§15
M
N
  • n-grams (-n) → §9
  • nanosecond timestamps → §4, §21
  • --no-export§17
  • NO_MOTION§20
O
P
  • prefix mode → §6
  • prefers_reduced_motion§20
  • --print0§8
  • atuin history prune§12
  • [pty_proxy]§18
R
  • records.db§17
  • atuin register§15
  • --reverse§8
S
  • atuin scripts, --last, --script§17
  • atuin search§8
  • search_mode§6
  • search_mode_shell_up_key_binding§6
  • secrets_filter§12
  • self-hosting → §15
  • server.toml§15
  • session column / filter → §4, §7
  • session-preload§7
  • --shell filter → §8
  • atuin stats§9
  • atuin store status/verify/rebuild → §14, §17
  • store_failed§11
  • SUN_LEN error → §16
  • atuin sync, -f§14
  • sync_address, sync_frequency§11, §14
  • syntax_highlight§20
  • systemd socket activation → §16
T
  • [theme], custom themes → §20
  • --timezone / --tz§8
  • [tmux] popup → §20
  • tombstones → §13
  • TOML table ordering trap → §11
U
  • unique constraint → §4, §10
  • "Unique commands" (stats) → §9
  • up-arrow binding → §5
  • atuin uuid§3
W
  • atuin wrapped§9
  • workspace filter mode → §7
  • workspaces§7
  • WSL specifics → §3, §16, §24
?

Retrieval quiz

Self-scored flashcards. Read the question, answer out loud, then reveal. Marking something "Review" is more useful than getting it right — it is the signal for where to reread. Aim for 14/17 before you consider yourself fluent.

Score: 0 learned · 0 review · 0/17 answered
Q1Atuin is not a nicer Ctrl+R. What is the one structural change it makes, and name three features that fall out of it?
Your history becomes a SQLite table with columns instead of a text file. From that: filter modes (there is a cwd and a session to filter on), exit-code search (exit is a column), sync (rows serialise and encrypt), stats (you can GROUP BY), and no size cap. (§1)
Q2Which two commands does your shell call for every command you run, and what does each write?
atuin history start from preexec writes command, cwd, session, hostname, shell, author and timestamp, returning a UUIDv7. atuin history end --exit N from precmd fills in duration and exit against that id. Two calls, one row. (§4)
Q3Your config change had no effect and produced no error. What is the first thing to check?
Whether the key landed inside a [table]. In TOML every key after a table header belongs to that table, so appending a top-level key to the bottom of the file silently nests it. Verified live: history_filter after [dotfiles] made prune report No entries to prune.; moving it above the first [ made the same filter find 6. Diagnose with atuin config print. (§11)
Q4Inside the search TUI, what do Ctrl+R and Ctrl+S do — and why is the first one surprising?
Ctrl+R cycles filter mode (global → host → session → workspace → directory → session-preload); Ctrl+S cycles search mode. Surprising because Ctrl+R is the key that opened the UI, so the instinct to press it again to step backwards now changes scope instead. (§5, §7)
Q5Why does a fuzzy search for git return rg --hidden TODO, and what is the one-character fix?
Fuzzy mode is subsequence matching: git appear in that order inside rg --hidden TODO. Not a bug — the definition. Fix: 'git, where ' forces an exact substring. Measured: 6 results drop to 4. (§6)
Q6Name the five fuzzy operators and what each does.
'term exact substring · ^term anchored to start · term$ anchored to end · !term exclude · a | b either. Space-separated terms are ANDed. Same language as fzf. (§6)
Q7Directory filter mode returns nothing even though you ran those commands in this project. What happened, and what is the fix?
Directory mode matches the exact cwd string. You are one cd below where the commands ran. Fix: workspaces = true and use workspace mode, which walks up to the enclosing git repo. Measured: 7 results in work/api, 0 in work/api/src. (§7)
Q8You want to script over atuin's output. Which flag, and why not --cmd-only?
--print0, read with while IFS= read -r -d ''. --cmd-only emits raw text, so a command containing a newline becomes two lines — measured: a 4-row database produced 5 lines from wc -l and 4 NULs from --print0. (§8)
Q9What does exit = -1 mean, and which rows have it?
Unknown. Every imported row has it, because shell history files never recorded exit codes. Imported rows also have cwd = unknown. Consequence: filter with exit >= 0 in any failure-rate query or your imports quietly count as successes. (§10, §21)
Q10Is running atuin import zsh twice dangerous? Why or why not?
No. The table has unique(timestamp, cwd, command), so re-importing the same file inserts nothing new. Measured: row count stayed at 4 across two imports. (§4, §10)
Q11Distinguish your atuin password from your atuin key. Which can be recovered?
The password authenticates you to the sync server and can be reset. The key (~/.local/share/atuin/key, printed by atuin key) encrypts and decrypts your records, never leaves your machines, and cannot be recovered — lose it and every server-side record is permanently undecryptable. Back it up before setting up a second machine. (§14)
Q12Why is deleting 100,000 entries on a synced account a bad idea, and what is the documented alternative?
Deletion writes a tombstone (deleted_at) rather than removing the row — that is how deletions reach other machines. So mass deletion is mass writing, and all of it syncs forever. Documented clean slate: atuin account delete, then atuin register, then optionally atuin import auto. (§13)
Q13Your daemon will not start and atuin daemon status just says "not running". What is the likely cause and how do you confirm it?
The socket path exceeds SUN_LEN (~104 bytes on macOS) — common when data_dir is relocated somewhere deep, since the socket defaults to living beside it. Confirm with atuin daemon start --show-logs, which surfaces Error: path must be shorter than SUN_LEN. Fix by setting a short daemon.socket_path. (§16)
Q14Three exclusion mechanisms act before a row is written and one acts after. Name them and say which is irreversible.
Before: leading space (your shell never reports it), secrets_filter (built-in credential shapes), history_filter and cwd_filter (your regexes). After: atuin history prune, which deletes existing rows matching those filters — that one is irreversible, so always --dry-run first. Filters only apply going forward. (§12)
Q15atuin stats reports "Unique commands: 26" on a database with 24 distinct command strings. What is it actually counting?
Distinct command + cwd pairs — measured to match count(distinct command||'|'||cwd). The same command run in two directories counts twice. Also note the buckets split pipelines, so bucket counts can exceed Total commands. Inferred from behaviour across two datasets, not from source. (§9)
Q16Of Atuin AI, the MCP server and agent hooks — which sends data off your machine, and what is enabled by default once AI is on?
Only Atuin AI sends data out; it needs an Atuin Hub account. MCP (atuin mcp) is local stdio, and agent hooks only write into your local history. Once AI is enabled, enable_command_execution, enable_file_tools, enable_history_search and enable_history_output are all true by default, gated only by permission prompts — which ai.yolo = true removes entirely. (§19)
Q17You installed atuin on Ubuntu under WSL and nothing is recorded. Give the two-command diagnosis and the most likely WSL-specific cause.
echo $ATUIN_SESSION (empty ⇒ the init never ran in this shell) and atuin doctor (is "atuin" in shell.plugins?). Most likely cause: WSL starts a login shell, which reads ~/.bash_profile rather than ~/.bashrc. If a ~/.bash_profile exists and does not source ~/.bashrc, your init line is never reached. (§3, §25)

Sources

Provenance Checked with atuin 18.19.0 (latest stable, released 2026-08-03) on macOS 15.6 · Darwin 24.6.0 · x86_64, zsh, sqlite_version 3.46.0 as reported by atuin doctor · 2026-08-07.

Measured: every transcript in §3–§13, §16, §17 and §21, produced in disposable ATUIN_CONFIG_DIR sandboxes. Doc drift and undocumented behaviour are flagged inline where found — atuin info's config path, the TOML nesting trap, scripts --script taking a file path, the SUN_LEN daemon failure, sync_frequency, enter_accept, wrapped's default year, and stats' "Unique commands".

Doc-sourced, not executed: all of §14 and §15 beyond --help output and resolved config values (no account was registered, no server was run); §18 in full (enabling pty-proxy alters the interactive shell in a way a scripted session cannot honestly exercise); §19's AI behaviour; and every WSL-specific claim — the same binary and documented WSL behaviour, but not run under WSL.

External links need network access. The guide itself works offline.
  • Atuin documentation — home — the version-pinned docs root. Best used for checking whether a feature exists in your version before hunting for it; the sidebar is the fastest map of the tool's surface.
  • Installation — per-platform install and shell wiring. Best used for the exact eval line for your shell, and the bash-preexec and ble.sh caveats.
  • Configuration — the annotated client config. Best used for discovering options you did not know existed; pair it with atuin default-config, which is the same content shipped in your binary and therefore always correct for your version.
  • Key binding and Advanced key binding — every key inside the TUI, plus vim keymap differences. Best used for the delete and context-switch prefix sequences, which are hard to discover by experiment.
  • Advanced usage — filter modes, search modes, context switching. Best used for the canonical one-line definition of each filter mode when you are deciding what to put in [search] filters.
  • Excluding commands — the four exclusion mechanisms with worked regex examples. Best used for writing your first history_filter, and for the reminder that filters are not retroactive.
  • Deleting history — every deletion path and how each interacts with sync. Best used for the documented clean-slate sequence, which is not the one you would guess.
  • Sync — register, login, key transfer. Best used for the second-machine setup; read it alongside §14's key-versus-password distinction, which the page states but does not belabour.
  • Self-hosting — server setup, plus Docker, Kubernetes and systemd. Best used for server.toml keys and the note that atuin server start was replaced by a separate atuin-server binary in 18.12.0 — the single most common reason an older tutorial fails.
  • daemon reference — lifecycle and configuration. Best used for deciding whether you need it at all; the answer is usually "not yet".
  • pty-proxy reference — the proxy, OSC 133, and output capture. Best used for understanding the shell re-exec caveat before you enable it, not after.
  • AI introduction, AI settings, MCP server, Agent hooks — the four AI-adjacent pages. Best used for the capability defaults table; read it before enabling AI, since command execution and file access are on by default.
  • Release notes on GitHubbest used for confirming the current stable version and checking whether a behaviour you rely on changed. Atuin ships often; 18.19.0 landed 2026-08-03 with three prereleases in the following four days.
  • bash-preexec — the library atuin bundles to give bash preexec/precmd. Best used for debugging bash-only recording problems, especially if another tool also installs it.
  • FAQ and Known issuesbest used for checking whether the odd behaviour you just hit is already documented, before filing anything.