Skip to main content

woswoar

Your shell history, on every machine, encrypted, without a server.

CI Python 3.10+ Runtime dependencies: 0 License: Apache-2.0

Austrian for "Was war?" — "what was it again?" — which is exactly what you ask when you need that one command from last Tuesday, on the other machine.

  woswoar (global) > docker
       2m  thinkpad  docker compose up -d --build
    3h12m  DT-24YYQ3 docker logs -f api
     6d4h  thinkpad  docker system prune -af
  ctrl-r global → host → session → dir, or ctrl-g/h/s, ctrl-o dir | ctrl-t timeline | ^name one machine

The machine column appears once you have more than one, and fzf matches on it — so typing thinkpad narrows to that machine. It shows the host part of each machine's name, because that is usually what differs between your own machines. The age of a command that exited non-zero is red, and Ctrl+R again cycles global → host → session → dir — where dir is this directory and everything below it, on every machine, because ~/src/woswoar on your laptop and on your desktop is the same project.

A short machine name is also an ordinary word. If yours is box, typing it finds sandbox and ~/dropbox too — so anchor it: ^box matches only the machine, because the search starts at the machine column and ^ sticks to the front of it. It composes with everything else, so ^box docker is "docker, on box" and ^box !docker is "on box, but not docker".

Find one command, then read around it

Half of what you want from history is not a command but the next one — you remember running the migration, and what you actually need is what you ran after it. Find anything, press Ctrl+T, and the list becomes the timeline either side of it, with the cursor still on what you found:

  woswoar (timeline global) >
    3h41m  git commit -m wip
    3h44m  git add -A
    3h58m  cargo test
     4h2m  vim src/lib.rs          <- where you were
    4h15m  cargo test
    4h15m  cargo build
    4h17m  cd ~/proj

Newest first, like every other list here. Scroll up into what came next, down into what led there, and press Enter on any of them. The search box is cleared, so typing now filters the timeline rather than repeating the search that got you here. Repeats are kept — running cargo test twice is the shape of what happened, and the deduplicated search list hides it.

See the rest of what was recorded

Six fields go into every record and a list line has room for two. Press Ctrl+/ for the other four on whichever row you are on:

when     2026-08-10 14:32:07  (3h12m ago)
dir      ~/src/woswoar
host     thinkpad
session  6a79f245-36ea53
exit     0
took     1.2 s

docker compose up -d --build

Which directory, which machine, which shell, how long it took, and the command in full rather than clipped at the window edge. dir, host and session are also three of the four scopes, so the pane says which key would narrow the list to whatever it is pointing at. A field nobody recorded says so rather than going missing — most of a freshly imported history.

The pane starts hidden and costs nothing until you ask for it — see the numbers.

Quick start

pipx install --force "git+https://github.com/martinus/woswoar.git@stable"
woswoar

woswoar on its own is the only command you have to remember. On a machine with nothing installed it sets up; after that it tells you where you stand and names the one command to run next, if there is one:

$ woswoar
woswoar 0.7.2 — 54,804 commands from 3 machines

1 machine(s) waiting to be accepted here:
    'martin@laptop'

Next:  woswoar accept

It reads only what is already here and never widens who can read your history — it names accept, it does not ask. Deciding that stays something you go and do, so the moment you are asked is one you chose.

woswoar setup asks four questions — it checks the tools, installs the shell hook, offers to import whatever history it finds, and asks for a sync repository (leave it blank to stay on one machine). Every step is a command you can also run yourself: install, import, init.

Open a new shell, press Ctrl+R. That is the whole thing on one machine.

[!TIP] The same line upgrades an existing install — run it again whenever you want the latest release. The shell hook is a copy rather than the packaged file, but it brings itself up to date on the next background sync, so there is nothing else to run; open a new shell to pick it up. stable tracks the most recent tag, so the command never changes and you never edit a version number on five machines. Swap @stable for @main to track the tip, or @v0.7.2 to pin exactly.

Coming from 0.6.x or earlier, run woswoar install once. Those versions had no background sync, so there is nothing running that could notice. woswoar and woswoar doctor both say so if it is skipped.

If --force fails with "A virtual environment already exists", your pipx is using uv as its backend and cannot reuse the old venv. Either UV_VENV_CLEAR=1 pipx install --force …, or the version that always works:

pipx uninstall woswoar
pipx install "git+https://github.com/martinus/woswoar.git@stable"

Neither touches your history: it lives in ~/.local/share/woswoar, not in the venv.

Needs: bash 5.0+ (Linux) · Python 3.10+ · fzf · age and git (sync only). woswoar install checks for these and prints the install command for your distribution. woswoar doctor diagnoses anything else that looks wrong.

[!WARNING] Do not install age as a snap. It works, and it starts a sandbox on every call — about 250 ms against 2 ms for a distribution binary. woswoar runs age roughly twice per day of recorded history, so on two years of history that is the difference between a sync taking three seconds and taking six minutes. woswoar doctor measures it and says so.

Adding another machine

Sync goes through an ordinary git repository you already own — no server, no account, no daemon. Create an empty one (woswoar-history on GitHub, a bare repo on a NAS, a folder on a USB stick). You do that once, ever.

Then on every machine, first or fifth, the same two lines:

pipx install --force "git+https://github.com/martinus/woswoar.git@stable"
woswoar                          # paste the repository URL when it asks

setup joins the repo and does the first sync itself, so the new machine starts publishing straight away — it signs its own commands and waits for nobody.

[!TIP] Importing atuin on more than one machine? setup asks whether to import only this machine's history, and on a fleet the answer is yes. atuin keeps every machine it has synced with in one database, and woswoar publishes only a machine's own commands — so importing all of them everywhere stores each machine's history once per machine. Let each machine import its own.

The same thing without the questions
woswoar install
woswoar import atuin --this-host-only     # optional
woswoar init git@github.com:you/woswoar-history.git

setup calls exactly these. It needs a terminal, so this is also what to use from a script or a dotfiles bootstrap.

From the second machine onwards, one more line, on each machine you already use:

woswoar accept

That is the whole of it. accept lists what is new, says what accepting it does, and asks:

$ woswoar accept
1 machine(s) not yet accepted here:

  'martin@work-laptop'
      reads with                  age1qjg…
      signs with                  SHA256:2xQ5…

Accepting does two separate things:

  read     1 machine(s) get to read your ENTIRE history, including
           days recorded before they existed. This is published, so it
           applies everywhere — and it cannot be taken back for what
           they have already read.

  believe  this machine will accept what 1 machine(s) publish.
           Local only: every other machine of yours has to be told
           separately, because the repository is the thing that decision
           defends against.

A name is free text written by whoever added the key. The fingerprints are
not — run these on the machine they belong to and compare:

    reads with   age-keygen -y ~/.config/woswoar/identity
    signs with   ssh-keygen -lf ~/.config/woswoar/signing_key.pub

Accept 1 machine(s)? [y/N]

Two keys, because there really are two questions, and they stay separate commands — grant for who may read, trust for whose word this machine believes. accept is both at once for the ordinary case where the machine is yours. The second one is why it has to be run on each machine you already own rather than once: the repository is somewhere anyone with push access can write, so what a machine believes cannot be decided by anything kept inside it. Revoking removes that decision everywhere automatically, since taking trust away can only ever cause a refusal.

[!TIP] .bashrc is written with $HOME rather than your username, so one shared dotfiles .bashrc works on every machine.

Sync automatically

Nothing to install — the shell hook does it. At most once a minute, and only on a machine somebody is actually typing on, it starts a woswoar sync in the background. Your prompt never waits for it: the shell hands the work to a detached process and returns immediately, so a slow git push cannot hold up a shell, and neither can a laptop that woke up on the wrong network.

# Sync at most every 5 minutes instead of every minute.
export WOSWOAR_SYNC_INTERVAL=300

An idle machine costs nothing, which is the point: a timer firing every minute on four machines is 5,760 fetches a day whether or not anyone typed anything. Recorded history reaches your other machines within a minute of you typing it, and about 6 MB of repository per machine per year — real typing is bursty, so a minute rather than five roughly doubles the syncs that carry anything, not quintuples them.

Keeping a machine current while nobody is using it

The trade is that a machine nobody types on never syncs, so it never receives either — a laptop left shut for a week is a week stale until the first command is typed. Opening a shell syncs, so in practice you are current by the time you have a prompt. If you would rather have a machine stay current while idle, a systemd timer does it. Paste the whole block:

mkdir -p ~/.config/systemd/user

cat > ~/.config/systemd/user/woswoar-sync.service <<'UNIT'
[Unit]
Description=woswoar shell history sync
Documentation=https://github.com/martinus/woswoar
# Pointless and noisy without a network; the timer will try again.
After=network-online.target

[Service]
Type=oneshot
# /usr/bin/env so this works wherever woswoar was installed (pipx, --user, venv)
# without hardcoding a path into the unit.
ExecStart=/usr/bin/env woswoar sync

# Sync holds a lock and talks to a remote; if it hangs, fail rather than pile up.
TimeoutStartSec=10min

# It only ever needs its own data directory, an ssh key, and the network.
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
UNIT

cat > ~/.config/systemd/user/woswoar-sync.timer <<'UNIT'
[Unit]
Description=Sync woswoar shell history periodically

[Timer]
# Wait a little after login rather than competing with everything else starting.
OnStartupSec=2min
# A minute, because it turns out to be nearly free -- real typing is bursty, so
# five minutes does not carry five times less. Raise it for the bytes back.
OnUnitActiveSec=1min

# Catch up after the machine was asleep or off, rather than silently skipping.
Persistent=true

# Every machine syncing on the same wall-clock tick is how you manufacture
# push races. A minute of jitter costs nothing and avoids them.
RandomizedDelaySec=60

[Install]
WantedBy=timers.target
UNIT

systemctl --user enable --now woswoar-sync.timer
export WOSWOAR_SYNC_INTERVAL=0   # and turn the hook's off, or you pay twice

Written out in full rather than copied out of the repository, because a pipx install leaves no checkout on the machine to copy from. Put the export in your .bashrc as well — pasted into a shell it lasts only as long as that shell, and the hook reads it on every prompt.

The two are safe to run together — syncs take a non-blocking lock, so whichever arrives second exits immediately — but there is no reason to.

If a background sync starts failing, nothing is on screen to say so. Typing woswoar on its own reports it, because a detached sync's error message would otherwise go nowhere at all.

Why woswoar?

Press Ctrl+R and fuzzy-search every command from every machine you own — deduplicated, newest first, with the working directory, exit code and duration recorded alongside. Pick one and it lands on your prompt for editing, never executed behind your back.

woswoar
🔐 Encrypted end to end commands, paths, hostnames — nothing readable reaches the remote
🧩 No server, no database a git repo and plain text files you can grep
📦 Zero Python dependencies standard library only — nothing to audit but this repo
~150 µs per command, zero forks the hook is pure bash; Python never runs on your prompt
🔎 fzf as the UI the fuzzy finder you already know, not a bespoke TUI
🚚 Imports what you have bash, zsh and atuin histories, idempotently
🧱 ~4300 lines of implementation small enough to read in an afternoon
🐤 Verifiable on your machine woswoar doctor --prove demonstrates, not asserts — see verify it yourself

[!NOTE] woswoar is a lighter alternative to atuin. If you want a sync server, a rich TUI and cross-platform support, atuin is the better tool. woswoar trades those for a design you can hold in your head.

Security

Everything that leaves your machine is encrypted with age — commands, paths, hostnames, even the directory names in the repo. Each machine keeps its own private key and no secret is ever copied between them. There is no crypto code here at all: age does it, and woswoar's wrapper is a few dozen lines of subprocess.

Your local history is plaintext, though, and metadata like "how many machines and how often they sync" is visible to anyone holding the repo.

None of that has to be taken on faith. woswoar doctor --prove records a canary command in a throwaway sandbox, syncs it, and shows you that it reaches the remote unreadable — and that is only the first of the checks you can run yourself, decrypting a chunk with stock age and no woswoar in the pipeline among them.

📖 The full security model — what is protected, what is not, and the guarantees CI asserts on every push. 🐤 Verify it yourself — checks you run on your own machine, none of which ask you to believe a document.

Coming from atuin

woswoar import atuin --dry-run   # see what would happen, changes nothing
woswoar import atuin

The database is opened read-only — it is very likely a running atuin's live database. atuin keeps every machine it has synced with in one file, and woswoar keeps those apart rather than flattening them, so --scope host and stats stay truthful. Re-running an import is idempotent.

[!TIP] Syncing several woswoar machines? Use --this-host-only on each. Sync publishes only a machine's own commands, so importing every atuin host on every machine would leave each peer's history stored twice. Import everything only if this stays your single woswoar machine.

Reference

command
woswoar where this machine stands, and what to run next
woswoar setup guided first run: tools, hook, import, sync repo
woswoar search interactive picker (what Ctrl+R runs)
woswoar list plain output, used by fzf's scope-switch reload
woswoar import bash|zsh|atuin import an existing history
woswoar stats entry counts, date range, most-used commands
woswoar doctor check the installation and the tools it needs
woswoar doctor --prove demonstrate in a sandbox that nothing readable is published
woswoar init [url] create or join an encrypted history repo
woswoar sync exchange history with the remote
woswoar accept add a machine you own: grant and trust at once
woswoar grant let newly enrolled machines read the older history
woswoar trust accept another machine's published history here
woswoar compact merge old chunks to reduce the working-tree file count
variable meaning
WOSWOAR_DIR data directory (default ~/.local/share/woswoar)
WOSWOAR_IGNORE extended regex of commands never to record
WOSWOAR_IGNORE_EXTRA extra regex joined onto the default, instead of replacing it
WOSWOAR_SYNC_INTERVAL seconds between background syncs; 0 turns them off (default 60)
WOSWOAR_SCOPE default scope for Ctrl+R: global, host, session or dir (default global)
WOSWOAR_NO_BIND set to skip binding Ctrl+R

Uninstalling

There is no woswoar uninstall, because every step is one you should see. In order, and each is independent:

# 1. Stop the timer, if you installed one. Syncing from the shell hook stops
#    with the hook itself, in step 2.
systemctl --user disable --now woswoar-sync.timer
rm -f ~/.config/systemd/user/woswoar-sync.{service,timer}

# 2. Remove the hook from your shell. `woswoar install` wrote a marked block;
#    delete the three lines between the markers, or:
sed -i '/# >>> woswoar >>>/,/# <<< woswoar <<</d' ~/.bashrc

# 3. Remove the program.
pipx uninstall woswoar

# 4. Remove its data. THIS DELETES YOUR RECORDED HISTORY -- see below first.
rm -rf ~/.local/share/woswoar ~/.config/woswoar ~/.cache/woswoar

Open a new shell afterwards; the current one still has the hook loaded.

Before you run step 4

~/.local/share/woswoar/logs/ is the only plaintext copy of what this machine recorded. history/ beside it is the encrypted git checkout, and ~/.config/woswoar/ holds this machine's identity and its signing key.

  • Keeping the history? Copy logs/ somewhere first. It is TSV, one command per line, readable without woswoar.
  • Other machines still syncing? Deleting local files does not remove this machine from the shared repository, and it does not stop peers accepting what it published. Run woswoar revoke <fingerprint> from another machine, otherwise its key stays in recipients.txt as a machine that can still read everything. woswoar grant on that other machine lists every enrolled machine by fingerprint and name, which is where that value comes from — it asks before changing anything, and does nothing at all if nothing is new. (woswoar accept shows the same fingerprints, but only for machines it has something left to do about.)
  • Reinstalling later? Deleting ~/.config/woswoar/ discards the identity. The machine can rejoin with woswoar init <url>, but it enrols as a new machine: woswoar accept has to be run again on every machine you keep. Keep that directory if you only meant to move the data.

The remote repository is untouched by all of this. Delete it separately if you want it gone, remembering that other machines still hold their own copies of everything in it.

How it works

bash hook  ──►  plaintext TSV logs  ──►  parse cache  ──►  scope filter  ──►  fzf
                        │
                        └──►  age-encrypted chunks  ──►  git  ──►  remote

The hot path is a fork-free bash hook using bash 5 builtins that appends one escaped line to a per-day TSV file. Nothing else touches your prompt. Everything expensive — parsing, caching, encrypting, git — happens when you search, or when the timer fires.

  • 🐚 Living in your shell — what it does to your bash, how it coexists with ble.sh, atuin and prompt frameworks, and what Ctrl-R costs.
  • 🔐 Security model — threat model, guarantees, limits.
  • 📐 Design summary — architecture, record format, the sync and encryption design, with measured numbers and the mistakes that shaped them.

Development

python -m tools.run_tests                               # 301 tests, sharded, ~6s
python -m unittest discover -s . -t . -p 'test_*.py'    # the same suite, serially
WOSWOAR_BENCH=1 python -m unittest tests.test_perf      # latency on 52k entries
ruff check . && ruff format --check . && mypy woswoar tests tools

The suite is about 88% subprocess wait — it drives real age, git and ssh-keygen rather than mocking them — so sharding it across processes takes it from ~19s to ~6s. Both commands run the same tests; the runner additionally fails if any test it discovered never reported back, which is a way a parallel run can be green that a serial one cannot.

CI runs lint, tests on Python 3.10/3.12/3.14, the shell-hook and fork-free checks, a two-machine end-to-end sync against real age and real git, immutability and repo-growth assertions, and an install smoke test.

Cutting a release

The version lives in woswoar/__init__.py and nowhere else — pyproject.toml reads it from there, so the two cannot disagree.

# 1. bump __version__, open a PR, merge it (main is protected)
# 2. tag the merged commit:
git tag v0.2.0 && git push origin v0.2.0

Everything after that is automatic. .github/workflows/release.yml refuses the tag unless it matches __version__ and sits on main, re-runs the whole suite at that exact commit, builds the sdist and wheel, publishes a GitHub release with generated notes, and fast-forwards stable — which is what the install command tracks. The stable push is not forced, so tagging an older commit fails loudly rather than moving everyone backwards.

License

Apache-2.0

Release files for woswoar 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for woswoar 0.9.0
File Size Uploaded
woswoar-0.9.0.tar.gz 378.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for woswoar 0.9.0
File Interpreter ABI Platform
woswoar-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 557.5 kB

Release files / woswoar-0.9.0.tar.gz

Download URL woswoar-0.9.0.tar.gz
Size 378.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a1abbc53292d147484d9e171d532ca4573dae26895595bb4a7099ce2e460d528
BLAKE2b-256 checksum
How to use checksums
798189426549ca12a2e57cc288358f2b3d8a79afb825d0dffae8aef73423a466
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release files / woswoar-0.9.0-py3-none-any.whl

Download URL woswoar-0.9.0-py3-none-any.whl
Size 179.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
98d7409cacadc21c6e7d86cbe134d77558753c2c16a74346faefa688020d879d
BLAKE2b-256 checksum
How to use checksums
7cca32030a0ed0027402fe1782f4b115373ecab523ffef33297a05df3c1ffda8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

This release

0.9.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page