A CLI for cataloguing files by metadata-driven rules. Walks input folders, extracts metadata, sorts files into a structured destination via templates, deduplicates by content hash, and tracks state so re-runs are cheap and deterministic.
Built first for photos and videos, but the pipeline is generic — profiles target documents, invoices, downloads, or anything with file-level metadata.
- Profile-driven. Each workflow is a single TOML file in
~/.config/shelf/. - Metadata-first sorting. EXIF for photos, QuickTime/MP4 for videos,
PDF
/Infofor documents. Falls back through filename patterns tomtime. - Templates for paths and filenames. Curly-brace tokens like
{yyyy}/{mm}/{dd}_{seq:05}and{camera}with:rawand width modifiers. - Per-day stable sequence numbers that survive reruns.
- Content-based dedupe via sha256. Resized or recompressed copies are treated as distinct files.
- Atomic file ops. Temp + fsync + rename — a crash never leaves a half-written file at the destination.
- Health checks: truncated files, missing capture date, hash drift, orphan files, unrouted files.
- Run history & revert. Every
shelf runis logged;shelf revert <id>undoes a prior run, op-mode-aware. - Ad-hoc imports via
--from /path— point shelf at any directory and use a profile's rules for one run. - Re-runnable. SQLite state DB tracks what's been placed; subsequent runs only act on new files.
- No daemon or watch mode — runs are explicit and one-shot. Schedule with cron or a systemd timer.
- No perceptual dedupe. Two visually similar files with different bytes are different files.
- No editing, transcoding, or thumbnail generation.
| Method | Command |
|---|---|
| Homebrew | brew tap franzos/tap && brew install shelf |
| Debian/Ubuntu | Download .deb — sudo dpkg -i shelf_*_amd64.deb |
| Fedora/RHEL | Download .rpm — sudo rpm -i shelf-*.x86_64.rpm |
| Guix | guix shell -m manifest.scm -- cargo build --release |
| Cargo | cargo build --release |
Pre-built binaries for Linux (x86_64), macOS (Apple Silicon, Intel) on GitHub Releases.
-
Write a profile. Easiest way: use the bundled Claude Code skill —
/shelf-profilewalks you through it. Or copy a sample from.claude/skills/shelf-profile/SKILL.mdand edit. -
See what shelf would do without touching anything:
shelf plan photos
-
Run it for real:
shelf run photos
-
Schedule reruns via cron — shelf will only act on files added since the last run.
shelf run [profile] [--from PATH]... [--dry-run] [--strict]
shelf plan [profile] [--from PATH]... # alias for run --dry-run
shelf health [profile] [--sample N] # diagnostic report
shelf verify [profile] [--full | --sample N] # rehash placements, flag drift
shelf runs [profile] [<id>] [--limit N] # run history; <id> shows placements
shelf revert [profile] <id> [--dry-run] [--force] # undo a prior run
shelf status # all profiles, counts, last run
shelf list # profiles in the config dir
Global flags: --config PATH, -v / -vv (verbosity). Every subcommand has
a --help with full details and examples.
Override a profile's inputs for one run — useful for SD cards, friend's photos, or any directory you want to import once:
shelf run photos --from /mnt/sdcard
shelf plan photos --from /a/path --from /another # repeatableFilters, dedupe, state DB, and sequence numbering all apply normally. Only the scan roots change.
Every shelf run writes a row to the runs table. shelf runs lists them
newest-first; shelf runs <id> shows the placements that run produced.
Each row carries a status:
(none)— finished cleanly.(dry-run)—--dry-run; nothing was placed.(incomplete)— the process died mid-run; placements may exist on disk without a finished row. Treat as "run again or revert manually".reverted by <id>— already undone by a later revert.
shelf revert <id> undoes a run. The op mode is remembered per placement:
copy/hardlink/symlink reverts delete the destination; move reverts put the
file back at its original source path.
shelf revert 42 # undo run 42 (default profile)
shelf revert photos 42 # undo run 42 of profile `photos`
shelf revert 42 --dry-run # preview
shelf revert 42 --force # override safety checksSafety checks refuse without --force when the destination has drifted
(someone edited the placed file) or when a move-revert would clobber an
existing source path. A few refusals are unconditional — --force won't
bypass them: the target run doesn't exist, was itself a dry-run, or was
itself a revert.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime error (I/O, SQLite, walk, hash, ...) |
| 2 | Structural error (profile not found, validation) |
| 3 | --strict promoted health entries to failure |
| 4 | health / verify found issues |
- Profiles:
~/.config/shelf/<name>.toml(override with$SHELF_CONFIG_DIR,$XDG_CONFIG_HOME, or--config PATH). - State:
$XDG_DATA_HOME/shelf/<profile>.db, falling back to~/.local/share/shelf/<profile>.db. - Profile schema reference:
.claude/skills/shelf-profile/SKILL.md.
The skill at .claude/skills/shelf-profile/SKILL.md has the full schema
plus three sample profiles (photos, invoices, downloads). Short version:
inputs = ["/abs/path/to/source"]
[filters]
include = ["*.jpg", "*.png", "*.mp4"]
exclude = ["**/cache/**"]
[kinds]
photo = ["jpg", "png", "heic"]
video = ["mp4", "mov"]
[metadata]
date_sources = ["exif:DateTimeOriginal", "quicktime:CreationDate", "filename", "mtime"]
[sequence]
scope = "day"
[dedupe]
strategy = "sha256"
on_duplicate = "skip"
[[output]]
name = "library"
path = "/abs/path/to/destination"
mode = "copy" # copy | move | hardlink | symlink
on_conflict = "rename" # skip | rename | replace | hash-suffix
preserve_mtime = true # default
directory = "{yyyy}/{mm}"
filename = "{yyyy}-{mm}-{dd}_{seq:05}"mode = "move" is destructive. Default to copy and confirm a few dry-runs
before switching. The state DB lets shelf detect already-placed files on
subsequent runs, so a copy-then-move migration is fine — you won't end up
with duplicates.
If a run goes sideways, shelf revert <id> will put copies back (delete
dests) or moves back (restore sources). It's not a substitute for backups,
but it's a fast first response.
The mental model: source folders are your ingestion queue, the destination is your library. Once shelf has placed a file, it's yours to cull, edit, rename, or move — shelf gets out of the way. The state DB tracks "I've handled this source file," not "this destination must exist."
What if I edit a destination file (Photoshop, sidecar, save-over)?
shelf leaves it alone. The source is unchanged, so dedupe skips on rerun and
your edit survives. shelf verify --full notes the byte mismatch as
health: drift; advisory, no action.
What if I delete a destination file?
The placement row still says "this sha256 is placed here," so dedupe treats
the source as handled and does not re-place it. shelf health surfaces
the absence as missing-destination. To get it back, drop the placement row
and rerun:
sqlite3 ~/.local/share/shelf/photos.db \
"DELETE FROM placements WHERE dest_path = '/path/to/file.jpg';"
shelf run photosWhat if I rename or move a destination file (e.g. organizing into albums)?
The original path shows up as missing-destination, the new path as orphan.
shelf doesn't know they're the same file, but it also doesn't undo your
reorganization.
What if I delete a source file?
Destination is untouched. shelf verify flags it as missing-source.
Rerunning just won't see that source again.
Why doesn't shelf re-place files I deleted from the destination? On purpose. A tool that "noticed" your culls and re-added them would be infuriating for a photo library. To force re-handling, drop the placement row (above).
How do I start over with a profile? Delete the state DB and the destination tree, then rerun:
rm ~/.local/share/shelf/photos.db
rm -rf /path/to/destination # or just empty the relevant subtrees
shelf run photosWhy does shelf health keep reporting old drift entries?
The health table accumulates entries; there's no auto-cleanup yet. They're
advisory. Clear them with:
sqlite3 ~/.local/share/shelf/photos.db "DELETE FROM health;"I just ran something I didn't mean to — can I undo it? Yes:
shelf runs photos # find the run id
shelf revert photos 42 --dry-run # see what would happen
shelf revert photos 42 # actually undo it