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.
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 defect | Why it happens | What atuin does |
|---|---|---|
| Two terminals overwrite each other's history | The file is written wholesale at shell exit; last writer wins | Each command is an immediate INSERT; concurrent sessions never collide |
You lose everything past HISTSIZE | The file is truncated to a fixed line count | No cap. A 5 MB database holds tens of thousands of commands (§21) |
| You can't tell which of two similar commands worked | Exit codes are never recorded | exit is a column; --exclude-exit 0 shows only failures (§8) |
| "What did I run in this project?" is unanswerable | No directory is stored | cwd is a column; directory filter mode uses it (§7) |
The architecture in one picture
daemon.enabled = true. The sync server never receives a key.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-proxyplus the daemon, and even then output lives in memory, not the database (§18). - It does not remove your native history file.
~/.zsh_historykeeps 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.
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
preexecandprecmdare 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.
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.
eval line in your rc fileatuin doctor and one real searchmacOS
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
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
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
| Concern | What happens | What 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. |
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.pluginscontaining"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.sync—nullmeans 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:
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.
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 printshows sandbox pathsatuin history listprints nothing (empty database)- Opening a new terminal and running
atuin history listshows your real history — proving the sandbox is per-shell
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.
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)
);
| Column | Type / unit | What it is for |
|---|---|---|
id | UUIDv7 as text | Primary key. Time-sortable, so ids and timestamps agree. Used by pty-proxy to key captured output. |
timestamp | nanoseconds since epoch | Ordering and date filters. Nanoseconds, not seconds — divide by 1e9 in SQL (§21). |
duration | nanoseconds | How long the command ran. 29568000 renders as 29ms. Zero for interrupted or imported rows. |
exit | integer | Exit status. -1 means "unknown" — every imported row has it (§10). |
command | text, may contain newlines | The literal command line. Multi-line commands are one row containing \n. |
cwd | absolute path, or unknown | Powers directory and workspace filter modes. unknown on imported rows. |
session | UUID | One value per shell session, from $ATUIN_SESSION. Powers session filter mode. |
hostname | host:user | Composite — measured as macbook.local:you. Powers host filter mode and identifies the machine after sync. |
deleted_at | nullable nanoseconds | Deletion is a tombstone, not a DELETE. This is how deletions propagate over sync (§13). |
author | text | Who ran it: your username, or an agent name like claude-code when agent hooks are installed (§19). |
intent | text | Free-text purpose, populated by agent hooks. Empty for commands you type. |
shell | text | Which shell ran it. Drives search.shells filtering. Empty on rows written before atuin tracked it. |
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.
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.
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.
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 -lreports 25timestamphas 19 digits — nanoseconds, not secondshostnamecontains a colon separating host from usershellis empty, becauseatuin history startinvoked 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 — ?.
?, 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.
| Posture | Init line | You get | You 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.
--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.
| Key | Action | Note |
|---|---|---|
| Ctrl+R | Cycle filter mode | global → host → session → workspace → directory → session-preload (§7). Yes: the key that opened the UI now means something else. |
| Ctrl+S | Cycle search mode | prefix / fulltext / fuzzy / daemon-fuzzy (§6) |
| Enter | Accept | Runs the command immediately when enter_accept = true; otherwise puts it on the prompt. See the trap below. |
| Tab | Accept for editing | Always 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+O | Open the inspector | Full metadata for the selected row — the fastest way to see cwd, host and exit |
| Ctrl+A then D | Delete the selected entry | Prefix-key sequence. Repeating it deletes all matching entries (§13) |
| Ctrl+A then C | Switch to the selected command's context | Jumps the filter to that command's session and clears the query. Press again to return. |
| Ctrl+Y | Copy to clipboard | |
| Ctrl+U · Ctrl+W · Ctrl+A · Ctrl+E | Standard readline line editing | Clear line · delete word · line start · line end |
| Esc · Ctrl+C · Ctrl+D · Ctrl+G | Cancel, restoring your original input |
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"]
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.
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.
- Type
git. Watch the list narrow. - Press Ctrl+S repeatedly. The mode indicator changes and the result set changes with it — this is §6 in action.
- Press Ctrl+R repeatedly. The filter indicator cycles. In
directorymode you will see nothing, because your current directory is not one of the seeded ones. - Select any row and press Ctrl+O. The inspector shows the full record from §4.
- 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
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
| Mode | A command matches when… | Best for | Fails at |
|---|---|---|---|
prefix | it starts with your query | "show me every git command" | finding a flag buried mid-command |
fulltext | it contains your query as a literal substring | hunting a hostname, a filename, a flag | typos; you must spell it right |
fuzzy default | your characters appear in order, gaps allowed | three-letter shorthand: gpm → git push origin main | precision — see the noise demo below |
daemon-fuzzy | same as fuzzy, but scored by an in-memory index in the daemon | very large histories where responsiveness matters | requires 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:
| Operator | Meaning | Verified example | Matched |
|---|---|---|---|
'term | exact substring — turns fuzzy into fulltext for this term | 'git | the 4 real git commands, nothing else |
^term | anchored to the start | ^git | same 4 — but would exclude sudo git … |
term$ | anchored to the end | main$ | git push origin main, git push --force-with-lease origin main |
!term | negation — exclude matches | !git | cargo/npm/docker/kubectl rows; no git rows |
a | b | OR within a term group | 'push | 'build | both 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
' 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.
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"
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.
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
prefixdoes thatfulltextdoes 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
| Mode | Restricts to | Column | Use it when |
|---|---|---|---|
global default | everything, every machine | — | you genuinely don't know where you ran it |
host | this machine only | hostname | synced setups: "that was on the Mac, not the WSL box" |
session | this shell session only | session | "what have I done in this window?" — the debugging scope |
directory | the current working directory | cwd | project-specific commands you half-remember |
workspace | the enclosing git repository | cwd, walked up | same, but you're in a subdirectory. Needs workspaces = true |
session-preload | this session, plus all global history from before it started | both | a compromise scope: recent context first, everything still reachable |
session-preload is the union of session and everything global older than it.~/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.
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
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
workspacemode 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
| Flag | Effect |
|---|---|
-c, --cwd <PATH> · --exclude-cwd | Restrict 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 · --after | Date bounds. Accepts human strings like "3 days ago" and "now" |
--limit · --offset | Pagination |
-r, --reverse | Oldest first |
--filter-mode · --search-mode | Override 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-duplicates | Off by default. Non-interactive search deduplicates; this restores every occurrence |
--timezone / --tz | Render times in another zone: local, +9, -05:30 |
Output shaping
Three flags decide what comes out, and the choice matters more than it looks:
| Flag | Output | Safe to pipe? |
|---|---|---|
| (none) | 2026-08-07 05:33:54 git status 22ms — tab-separated time, command, duration | Only if no command contains a tab or newline |
--cmd-only | The command text alone | No. Multi-line commands become multiple output lines |
--print0 | NUL-terminated records | Yes. This is the correct flag for scripting |
-f, --format | A template you define | Depends on your template |
--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
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}"
--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.
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
--print0with aread -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
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].
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
| Field | zsh extended history | plain bash history |
|---|---|---|
command | preserved, including \-continuation multi-line commands | preserved |
timestamp | real, from the : 1750000000:… prefix | synthesised at import time, 1 ms apart, in file order |
duration | real, from the elapsed-seconds field (3 → 3s) | zero |
exit | -1 (unknown) — zsh never recorded it | -1 |
cwd | unknown | unknown |
hostname | the importing machine — not where the command originally ran | same |
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.
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
.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.
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=-1andcwd=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
4vs5discrepancy
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
| Command | Answers |
|---|---|
atuin config get KEY | What is in my file. Prints (not set in config file) when absent |
atuin config get KEY -r | The resolved value after defaults and overrides — what atuin will actually use |
atuin config get KEY -v | Both, side by side. Use this one when debugging |
atuin config set KEY VALUE | Writes to your file. Dotted keys work: atuin config set dotfiles.enabled true |
atuin config set KEY VALUE -t TYPE | Force string / boolean / integer / float instead of guessing |
atuin config print | The whole loaded file as TOML |
atuin default-config | 404 lines of annotated template. Read it; don't blindly copy it |
[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:
| Key | Resolved default | Why you might change it |
|---|---|---|
search_mode | fuzzy | Set search_mode_shell_up_key_binding = "prefix" alongside it (§6) |
filter_mode | (unset — behaves as global) | workspace if you live in repos |
sync_frequency | 5m | 0 syncs after every command |
sync_address | https://api.atuin.sh/ | Point at your own server (§15) |
auto_sync | false (in this build) | See the drift note below |
enter_accept | false built-in / true in default-config | The trap from §5. Set it explicitly |
keymap_mode | emacs | vim-insert / vim-normal / auto |
secrets_filter | true | Leave it on (§12) |
store_failed | true | Set false and you lose the --exclude-exit 0 workflow. Don't |
inline_height | 40 | Lines the TUI may occupy |
show_preview | true | Preview pane under the results |
style | compact | full for a bordered UI |
workspaces | false | Turn on to enable workspace filter mode (§7) |
prefers_reduced_motion | false | Or set NO_MOTION=true in the environment |
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
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
| Mechanism | When it acts | Scope | Reversible? |
|---|---|---|---|
| Leading space | Your shell never tells atuin | One command, decided as you type | N/A — never recorded |
secrets_filter | Atuin refuses the write | Automatic pattern match on credentials | Off via config, but leave it on |
history_filter | Atuin refuses the write | Your regexes against the command text | Yes, edit the list |
cwd_filter | Atuin refuses the write | Your regexes against the directory | Yes |
atuin history prune | After the fact | Deletes existing rows matching the two filters above | No — 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
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.
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.
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
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 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
psqlcommand was refused at write time - You saw tombstoned rows still present in the table
- You ran
--dry-runbefore 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
| Method | Scope | Preview first? |
|---|---|---|
| Ctrl+A then D in the TUI | The selected entry. Repeating deletes all matching entries | You can see the row; use Ctrl+O to inspect it first |
atuin search --delete QUERY | Everything the query matches. Prints nothing | Yes — run the same query without --delete |
atuin history prune | Everything matching your config filters (§12) | Yes — --dry-run |
atuin history dedup | Duplicates sharing command + cwd + hostname | Yes — -n / --dry-run |
atuin search --delete-it-all | Every row in the local database. Cannot be combined with a query | There 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
--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.
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.
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
| Command | What it does |
|---|---|
atuin register -u USER -e EMAIL | Create an account and generate a key locally |
atuin login -u USER | Log in on another machine. Prompts for password and key |
atuin key | Print the encryption key for transfer |
atuin sync | Sync now |
atuin sync -f | Force a full re-download. The repair tool when a machine looks incomplete |
atuin status | Sync status. Errors when logged out — that is normal for local-only |
atuin store push / pull | One-way record transfer. Recovery tools, not daily use |
atuin store verify | Check that every record decrypts with the current key. Run this after any key change |
atuin store purge | Delete records that cannot be decrypted. Irreversible |
atuin store rekey | Re-encrypt with a new key. Flagged in --help as "potential for data loss!" |
atuin account change-password | Change the server password. Does not touch the key |
atuin account delete | Delete the account and all synced data. Irreversible |
--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).
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-hosted | No sync | |
|---|---|---|---|
| Setup effort | Two commands | An evening, plus a reverse proxy and TLS | Zero |
| Ongoing effort | None | Backups, upgrades, cert renewal, disk | None |
| Server sees your commands | No — ciphertext only | No, and it is your box | N/A |
| Server sees metadata | Username, email, password hash, host ids, record counts and timing | Same, on hardware you control | Nothing |
| Multi-machine history | Yes | Yes | No — the whole point of your setup |
| Survives a laptop dying | Yes, if you kept the key | Yes, if you kept the key and the backups | No |
| Fails when | The service is down — locally you are unaffected | Your box is down, or your cert expired | Never |
| Cost | Free tier | Whatever the box costs | Free |
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
.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.
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 key | Default | Notes |
|---|---|---|
host | 127.0.0.1 | Loopback by default — deliberate. Bind 0.0.0.0 only behind a proxy |
port | 8888 | |
open_registration | false | Turn on, register your accounts, turn back off and restart |
db_uri | required | PostgreSQL or SQLite |
path | empty | Route prefix when mounted under a subpath |
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.
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 statuserror as normal store statusshowed a record index matching your history size- You located the
keyfile 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 auto → atuin register → atuin 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-fuzzysearch mode (§6) and command-output capture withpty-proxy(§18). Neither works without it. atuin history tailstreams 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
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.)
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.
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 statusreportedHealthy: trueon your own socket- A command recorded through the daemon appeared in
history list - You triggered and read the
SUN_LENerror 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.
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
--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.
.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
--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':
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.
--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
--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
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.
# 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
--scriptfile-path requirement (or avoided it knowingly) - A template variable prompted when not supplied
atuin store statuslists 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_outputMCP 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)"
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.
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.
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.
| Feature | Direction | Data leaves your machine? | Needs an account? |
|---|---|---|---|
Atuin AI (?, atuin ai) | You ask a model for a command | Yes — your prompt goes to the configured endpoint | Yes, an Atuin Hub account (or your own endpoint) |
MCP server (atuin mcp) | A local agent queries your history | No — stdio, on your machine | No |
Agent hooks (atuin hook install) | An agent's commands get recorded into your history | No | No |
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:
| Key | Default | Meaning |
|---|---|---|
ai.enabled | false | Master switch. Also removable at init with --disable-ai |
ai.model | unset | Model for new sessions |
ai.endpoint | null | Custom or self-hosted backend |
ai.api_token | null | Token for a custom endpoint |
ai.endpoint_protocol | "auto" | auto · hub · oss |
ai.session_continue_minutes | 60 | How long a conversation stays warm |
ai.db_path | ai_sessions.db | Conversations stored in the atuin data dir |
ai.yolo | false | Auto-approves every permission check. See below |
ai.capabilities.enable_history_search | true | Model may search your history |
ai.capabilities.enable_history_output | true | Model may read captured command output (§18) |
ai.capabilities.enable_file_tools | true | Model may read files |
ai.capabilities.enable_command_execution | true | Model may run commands |
ai.opening.send_cwd | false | Include your directory in the opening context |
ai.opening.send_last_command | false | Include your previous command |
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
--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.
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.
--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.
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.
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).
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.
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… | Use | Because |
|---|---|---|
| remember the first word | prefix | Zero noise. Ideal on the up-arrow binding |
| remember a distinctive substring | fulltext | Literal, predictable. Best for hostnames and filenames |
| remember the shape, not the spelling | fuzzy recommended default | Most forgiving; use ' to tighten it when it goes wide |
| have a huge history and run the daemon | daemon-fuzzy | Scored by an in-memory index. No benefit without the daemon |
Filter mode
| Question | Mode |
|---|---|
| "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 on — directory breaks one cd down |
| "Recent context first, but keep everything reachable" | session-preload |
Sync
| Situation | Choice |
|---|---|
| Two personal machines you both use interactively | Hosted — contents are encrypted before they leave |
| A policy that forbids third-party storage even of ciphertext | Self-host behind a reverse proxy with TLS |
| You already run a homelab with backups | Self-host; the marginal cost is genuinely low |
| Shared, borrowed, or production machines | No sync. Consider not installing atuin at all there |
| One machine, ever | No sync — but back up ~/.local/share/atuin/ |
Daemon
| You want… | Daemon? |
|---|---|
| Lower prompt latency on a very large history | Yes — but measure first |
daemon-fuzzy search | Required |
| Command output capture for agents | Required, plus pty-proxy |
| Time-based sync regardless of activity | Yes |
| Fewest moving parts | No. This is the right default for a new user |
atuin versus the alternatives
| Plain HISTFILE | fzf + Ctrl-R | atuin | |
|---|---|---|---|
| Storage | Text file, capped | Same text file | SQLite, uncapped |
| Concurrent shells | Last writer wins | Same problem | Correct |
| Fuzzy search | No | Yes, excellent | Yes, same operators |
| Directory context | No | No | Yes |
| Exit codes | No | No | Yes |
| Cross-machine | No | No | Yes, E2E encrypted |
| Setup cost | None | Minutes | Minutes, plus a config you will tune |
| Failure mode | Silent data loss | Inherits the file's problems | A 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 instinct | The atuin move | Why 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) |
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.
~/.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.
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.
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.
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.
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.
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.
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.
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.
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
| Capability | Weak | Strong |
|---|---|---|
| Search | Types a query and scrolls | Reaches for a filter or a flag before scrolling |
| Modes | Cycles until it looks right | Names the mode they want and why |
| Config | Pastes snippets | Checks config print ordering and config get -v after every change |
| Exclusion | Deletes after the fact | Adds a filter, then prunes once |
| Sync | "It syncs" | Explains key vs password, and where the key is backed up |
| Debugging | Reinstalls | Runs atuin doctor, reads shell.plugins, checks the daemon log |
| Beyond history | Only uses Ctrl+R | Has 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
| Symptom | Likely cause | Fix |
|---|---|---|
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 v → unexpected 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) |
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
| Key | Default | Common setting |
|---|---|---|
search_mode | fuzzy | fuzzy |
search_mode_shell_up_key_binding | = search_mode | prefix |
filter_mode | global | global |
workspaces | false | true |
enter_accept | false built-in / true in template | Set it explicitly |
sync_frequency | 5m | 0 for two machines |
secrets_filter | true | leave on |
store_failed | true | leave on |
[search] filters | all six | ["global","workspace","session"] |
[ui] columns | ["duration","time","command"] | add "host" when syncing |
[dotfiles] enabled | false | true |
[daemon] enabled | false | only with a reason |
ai.enabled | false | --disable-ai at init too |
Paths
| What | Where |
|---|---|
| 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/key — back 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 installthat makes an AI coding agent record the commands it runs into your atuin history, tagged with the agent asauthor. (§19) - 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-userand$all-agent. (§4, §19) - bash-preexec
- A third-party shell library that gives bash the
preexec/precmdhooks zsh has natively. Atuin bundles a copy insideatuin init bash; suppress it withATUIN_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-fuzzysearch 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
fuzzyfor 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 setstrue. (§5) - exit code
- The status a command returned, stored per row.
-1means unknown — every imported row has it. (§4, §10) - filter mode
- The scope a search runs against:
global,host,session,directory,workspaceorsession-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 withatuin 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/keythat encrypts and decrypts your records. Printed byatuin 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_historyandatuin_output, to local AI agents. Sends nothing off-machine. (§19)- n-gram (stats)
- The
-noption toatuin 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-proxyreads 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 endandhistory startto them. (§4) - prune
atuin history prune— retroactively deletes rows matching your currenthistory_filterandcwd_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;--scriptexpects a file path. (§17) - search mode
- How a query is matched:
prefix,fulltext,fuzzyordaemon-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_SESSIONby 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_atrather 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
--after→ §8- agent hooks → §19
atuin account delete→ §13, §14atuin ai→ §19ai.yolo→ §19--author,$all-agent,$all-user→ §19ATUIN_CONFIG_DIR→ §3, §11ATUIN_HISTORY_ID→ §4ATUIN_NO_BUILTIN_PREEXEC→ §3ATUIN_SESSION→ §4, §25ATUIN_THEME_DIR→ §20ATUIN_TMUX_POPUP→ §20auto_sync→ §11
--cmd-only(and its trap) → §8columns→ §5, §20command_chaining→ §11common_subcommands→ §9atuin config get -r / -v→ §11atuin config print→ §11Ctrl-A D(delete) → §13Ctrl-O(inspector) → §5Ctrl-R(cycles filter) → §5, §7Ctrl-S(cycles search) → §5, §6cwdcolumn → §4cwd_filter→ §12
- daemon → §16
daemon-fuzzy→ §6atuin default-config→ §11--delete,--delete-it-all→ §13deleted_at→ §4, §13atuin history dedup→ §13- directory filter mode → §7
--disable-ai→ §5, §19--disable-ctrl-r,--disable-up-arrow→ §5atuin doctor→ §3, §25atuin dotfiles→ §17--dry-run→ §12, §13--dupkeep→ §13duration(nanoseconds) → §4, §21
enter_accept→ §5, §11--exclude-cwd→ §7--exclude-exit→ §8exitcolumn,-1→ §4, §10EXTENDED_HISTORY→ §10extra_headers→ §15
- filter modes → §7
[search] filters→ §7--format→ §8- fulltext mode → §6
- fuzzy operators
' ^ $ ! |→ §6 - fzf comparison → §22
- global filter mode → §7
HISTCONTROL=ignorespace→ §12HISTFILE(for import) → §10HIST_IGNORE_SPACE→ §12atuin history start/end→ §4atuin history tail→ §16history_filter→ §12history.db→ §4, §21atuin hook install→ §19hostname(host:user) → §4- host filter mode → §7
atuin import→ §10--include-duplicates→ §8atuin info(and its bug) → §3atuin init→ §3, §5inline_height→ §11intentcolumn → §4
- prefix mode → §6
prefers_reduced_motion→ §20--print0→ §8atuin history prune→ §12[pty_proxy]→ §18
atuin scripts,--last,--script→ §17atuin search→ §8search_mode→ §6search_mode_shell_up_key_binding→ §6secrets_filter→ §12- self-hosting → §15
server.toml→ §15sessioncolumn / filter → §4, §7session-preload→ §7--shellfilter → §8atuin stats→ §9atuin storestatus/verify/rebuild → §14, §17store_failed→ §11- SUN_LEN error → §16
atuin sync,-f→ §14sync_address,sync_frequency→ §11, §14syntax_highlight→ §20- systemd socket activation → §16
[theme], custom themes → §20--timezone/--tz→ §8[tmux]popup → §20- tombstones → §13
- TOML table ordering trap → §11
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.
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)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)[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)git return rg --hidden TODO, and what is the one-character fix?g … i … t 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)'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)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)--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)exit = -1 mean, and which rows have it?atuin import zsh twice dangerous? Why or why not?~/.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)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)atuin daemon status just says "not running". What is the likely cause and how do you confirm it?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)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)atuin stats reports "Unique commands: 26" on a database with 24 distinct command strings. What is it actually counting?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)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)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
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
evalline 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.tomlkeys and the note thatatuin server startwas replaced by a separateatuin-serverbinary 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 GitHub — best 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 issues — best used for checking whether the odd behaviour you just hit is already documented, before filing anything.