{"id":50672082,"url":"https://github.com/franzos/shelf","last_synced_at":"2026-06-08T12:04:16.453Z","repository":{"id":357415844,"uuid":"1236839840","full_name":"franzos/shelf","owner":"franzos","description":"A CLI for cataloguing files by metadata-driven rules. ","archived":false,"fork":false,"pushed_at":"2026-05-12T16:46:51.000Z","size":167,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-12T18:35:15.245Z","etag":null,"topics":["cli","organizer","shelf"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/franzos.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-12T16:11:45.000Z","updated_at":"2026-05-12T16:57:19.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/franzos/shelf","commit_stats":null,"previous_names":["franzos/shelf"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/franzos/shelf","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fshelf","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fshelf/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fshelf/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fshelf/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/franzos","download_url":"https://codeload.github.com/franzos/shelf/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/franzos%2Fshelf/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34061126,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-08T02:00:07.615Z","response_time":111,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cli","organizer","shelf"],"created_at":"2026-06-08T12:04:14.791Z","updated_at":"2026-06-08T12:04:16.433Z","avatar_url":"https://github.com/franzos.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# shelf\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"assets/logo.svg\" alt=\"shelf\" width=\"480\"\u003e\n\u003c/p\u003e\n\u003cp align=\"center\"\u003e\n  A CLI for cataloguing files by metadata-driven rules. Walks input folders,\n  extracts metadata, sorts files into a structured destination via templates,\n  deduplicates by content hash, and tracks state so re-runs are cheap and\n  deterministic.\n\u003c/p\u003e\n\nBuilt first for photos and videos, but the pipeline is generic — profiles\ntarget documents, invoices, downloads, or anything with file-level metadata.\n\n## Features\n\n- **Profile-driven**. Each workflow is a single TOML file in `~/.config/shelf/`.\n- **Metadata-first sorting**. EXIF for photos, QuickTime/MP4 for videos,\n  PDF `/Info` for documents. Falls back through filename patterns to `mtime`.\n- **Templates for paths and filenames**. Curly-brace tokens like\n  `{yyyy}/{mm}/{dd}_{seq:05}` and `{camera}` with `:raw` and width modifiers.\n- **Per-day stable sequence numbers** that survive reruns.\n- **Content-based dedupe** via sha256. Resized or recompressed copies are\n  treated as distinct files.\n- **Atomic file ops**. Temp + fsync + rename — a crash never leaves a\n  half-written file at the destination.\n- **Health checks**: truncated files, missing capture date, hash drift,\n  orphan files, unrouted files.\n- **Run history \u0026 revert**. Every `shelf run` is logged; `shelf revert \u003cid\u003e`\n  undoes a prior run, op-mode-aware.\n- **Ad-hoc imports** via `--from /path` — point shelf at any directory and\n  use a profile's rules for one run.\n- **Re-runnable**. SQLite state DB tracks what's been placed; subsequent runs\n  only act on new files.\n\n## Non-goals\n\n- No daemon or watch mode — runs are explicit and one-shot. Schedule with\n  cron or a systemd timer.\n- No perceptual dedupe. Two visually similar files with different bytes are\n  different files.\n- No editing, transcoding, or thumbnail generation.\n\n## Install\n\n| Method | Command |\n|--------|---------|\n| Homebrew | `brew tap franzos/tap \u0026\u0026 brew install shelf` |\n| Debian/Ubuntu | Download [`.deb`](https://github.com/franzos/shelf/releases) — `sudo dpkg -i shelf_*_amd64.deb` |\n| Fedora/RHEL | Download [`.rpm`](https://github.com/franzos/shelf/releases) — `sudo rpm -i shelf-*.x86_64.rpm` |\n| Guix | `guix shell -m manifest.scm -- cargo build --release` |\n| Cargo | `cargo build --release` |\n\nPre-built binaries for Linux (x86_64), macOS (Apple Silicon, Intel) on [GitHub Releases](https://github.com/franzos/shelf/releases).\n\n## Quickstart\n\n1. **Write a profile.** Easiest way: use the bundled Claude Code skill —\n   `/shelf-profile` walks you through it. Or copy a sample from\n   `.claude/skills/shelf-profile/SKILL.md` and edit.\n\n2. **See what shelf would do** without touching anything:\n\n   ```bash\n   shelf plan photos\n   ```\n\n3. **Run it for real**:\n\n   ```bash\n   shelf run photos\n   ```\n\n4. **Schedule reruns** via cron — shelf will only act on files added since\n   the last run.\n\n## Subcommands\n\n```\nshelf run    [profile] [--from PATH]... [--dry-run] [--strict] [--all]\nshelf plan   [profile] [--from PATH]... [--all]        # alias for run --dry-run\nshelf health [profile] [--sample N]                    # diagnostic report\nshelf verify [profile] [--full | --sample N]           # rehash placements, flag drift\nshelf runs   [profile] [\u003cid\u003e] [--limit N]              # run history; \u003cid\u003e shows placements\nshelf revert [profile] \u003cid\u003e [--dry-run] [--force]      # undo a prior run\nshelf status                                           # all profiles, counts, last run\nshelf list                                             # profiles in the config dir\n```\n\nGlobal flags: `--config PATH`, `-v` / `-vv` (verbosity). Every subcommand has\na `--help` with full details and examples.\n\n## Ad-hoc import\n\nOverride a profile's inputs for one run — useful for SD cards, friend's\nphotos, or any directory you want to import once:\n\n```bash\nshelf run photos --from /mnt/sdcard\nshelf plan photos --from /a/path --from /another  # repeatable\n```\n\nFilters, dedupe, state DB, and sequence numbering all apply normally. Only\nthe scan roots change.\n\n## Run history \u0026 revert\n\nEvery `shelf run` writes a row to the `runs` table. `shelf runs` lists them\nnewest-first; `shelf runs \u003cid\u003e` shows the placements that run produced.\n\nEach row carries a status:\n\n- `(none)` — finished cleanly.\n- `(dry-run)` — `--dry-run`; nothing was placed.\n- `(incomplete)` — the process died mid-run; placements may exist on disk\n  without a finished row. Treat as \"run again or revert manually\".\n- `reverted by \u003cid\u003e` — already undone by a later revert.\n\n`shelf revert \u003cid\u003e` undoes a run. The op mode is remembered per placement:\ncopy/hardlink/symlink reverts delete the destination; move reverts put the\nfile back at its original source path.\n\n```bash\nshelf revert 42                    # undo run 42 (default profile)\nshelf revert photos 42             # undo run 42 of profile `photos`\nshelf revert 42 --dry-run          # preview\nshelf revert 42 --force            # override safety checks\n```\n\nSafety checks refuse without `--force` when the destination has drifted\n(someone edited the placed file) or when a move-revert would clobber an\nexisting source path. A few refusals are unconditional — `--force` won't\nbypass them: the target run doesn't exist, was itself a dry-run, or was\nitself a revert.\n\n## Exit codes\n\n| Code | Meaning                                            |\n| ---- | -------------------------------------------------- |\n| 0    | Success                                            |\n| 1    | Runtime error (I/O, SQLite, walk, hash, ...)       |\n| 2    | Structural error (profile not found, validation)   |\n| 3    | `--strict` promoted health entries to failure      |\n| 4    | `health` / `verify` found issues                   |\n\n## Where things live\n\n- **Profiles**: `~/.config/shelf/\u003cname\u003e.toml` (override with\n  `$SHELF_CONFIG_DIR`, `$XDG_CONFIG_HOME`, or `--config PATH`).\n- **State**: `$XDG_DATA_HOME/shelf/\u003cprofile\u003e.db`, falling back to\n  `~/.local/share/shelf/\u003cprofile\u003e.db`.\n- **Profile schema reference**: `.claude/skills/shelf-profile/SKILL.md`.\n\n## Profile reference\n\nThe skill at `.claude/skills/shelf-profile/SKILL.md` has the full schema\nplus three sample profiles (photos, invoices, downloads). Short version:\n\n```toml\ninputs = [\"/abs/path/to/source\"]\n\n[filters]\ninclude = [\"*.jpg\", \"*.png\", \"*.mp4\"]\nexclude = [\"**/cache/**\"]\n\n[kinds]\nphoto = [\"jpg\", \"png\", \"heic\"]\nvideo = [\"mp4\", \"mov\"]\n\n[metadata]\ndate_sources = [\"exif:DateTimeOriginal\", \"quicktime:CreationDate\", \"filename\", \"mtime\"]\n\n[sequence]\nscope = \"day\"\n\n[dedupe]\nstrategy = \"sha256\"\non_duplicate = \"skip\"\n\n[[output]]\nname = \"library\"\npath = \"/abs/path/to/destination\"\nmode = \"copy\"             # copy | move | hardlink | symlink\non_conflict = \"rename\"    # skip | rename | replace | hash-suffix\npreserve_mtime = true     # default\ndirectory = \"{yyyy}/{mm}\"\nfilename  = \"{yyyy}-{mm}-{dd}_{seq:05}\"\n```\n\n## A note on safety\n\n`mode = \"move\"` is destructive. Default to `copy` and confirm a few dry-runs\nbefore switching. The state DB lets shelf detect already-placed files on\nsubsequent runs, so a `copy`-then-`move` migration is fine — you won't end up\nwith duplicates.\n\nIf a run goes sideways, `shelf revert \u003cid\u003e` will put copies back (delete\ndests) or moves back (restore sources). It's not a substitute for backups,\nbut it's a fast first response.\n\n## FAQ\n\nThe mental model: **source folders are your ingestion queue**, **the\ndestination is your library**. Once shelf has placed a file, it's yours to\ncull, edit, rename, or move — shelf gets out of the way. The state DB tracks\n\"I've handled this source file,\" not \"this destination must exist.\"\n\n**What if I edit a destination file (Photoshop, sidecar, save-over)?**\nshelf leaves it alone. The source is unchanged, so dedupe skips on rerun and\nyour edit survives. `shelf verify --full` notes the byte mismatch as\n`health: drift`; advisory, no action.\n\n**What if I delete a destination file?**\nThe placement row still says \"this sha256 is placed here,\" so dedupe treats\nthe source as handled and **does not** re-place it. `shelf health` surfaces\nthe absence as `missing-destination`. To get it back, drop the placement row\nand rerun:\n\n```bash\nsqlite3 ~/.local/share/shelf/photos.db \\\n  \"DELETE FROM placements WHERE dest_path = '/path/to/file.jpg';\"\nshelf run photos\n```\n\n**What if I rename or move a destination file (e.g. organizing into albums)?**\nThe original path shows up as `missing-destination`, the new path as `orphan`.\nshelf doesn't know they're the same file, but it also doesn't undo your\nreorganization.\n\n**What if I delete a source file?**\nDestination is untouched. `shelf verify` flags it as `missing-source`.\nRerunning just won't see that source again.\n\n**Why doesn't shelf re-place files I deleted from the destination?**\nOn purpose. A tool that \"noticed\" your culls and re-added them would be\ninfuriating for a photo library. To force re-handling, drop the placement\nrow (above).\n\n**How do I start over with a profile?**\nDelete the state DB and the destination tree, then rerun:\n\n```bash\nrm ~/.local/share/shelf/photos.db\nrm -rf /path/to/destination          # or just empty the relevant subtrees\nshelf run photos\n```\n\n**Why does `shelf health` keep reporting old drift entries?**\nThe `health` table accumulates entries; there's no auto-cleanup yet. They're\nadvisory. Clear them with:\n\n```bash\nsqlite3 ~/.local/share/shelf/photos.db \"DELETE FROM health;\"\n```\n\n**I just ran something I didn't mean to — can I undo it?**\nYes:\n\n```bash\nshelf runs photos                # find the run id\nshelf revert photos 42 --dry-run # see what would happen\nshelf revert photos 42           # actually undo it\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffranzos%2Fshelf","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffranzos%2Fshelf","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffranzos%2Fshelf/lists"}