A terminal filesystem tagging tool (in the spirit of TMSU). Acervus organizes files across your disk with marks (labels) and stacks (named groups), keeping the index in a local SQLite database while your files stay exactly where they are.
📖 User's manual — acervus.fancysnake.dev
(source in docs/)
Status: MVP complete. Acervus scans your configured roots, browses the indexed files, and marks and stacks them from the TUI, with filtering on each.
FEATURE_PLAN.mdrecords how it was built.
| Term | Meaning |
|---|---|
| root | A named directory (alias -> path) that Acervus indexes. |
| file | A file discovered under a root, tracked by its path relative to it. |
| mark | A label attached to files (many-to-many). |
| stack | A named group a file can belong to. |
Naming: acervus is the project, acre is the installed command, mark is a label, stack is a file group.
# Install the toolchain and dependencies (mise reads mise.toml / pyproject.toml)
mise install
poetry installThis exposes the acre command (entry point acervus.inits.wiring:main).
Acervus reads a TOML config from ~/.config/acervus/config.toml.
See config.example.toml:
[acervus]
db_path = "~/.local/share/acervus/acervus.db"
# ignore = [".git", ".venv", "node_modules", "__pycache__"]
[acervus.roots]
# docs = "/home/user/docs"
# photos = "/home/user/photos"db_path— where the SQLite index lives.[acervus.roots]— the named directories Acervus manages (alias = "path").ignore— glob patterns a scan skips, matched against one path component at a time:.venvskips a directory of that name at any depth,*.pycskips a file. An ignored directory is pruned, not walked. Writing the key replaces the default list shown above;ignore = []indexes everything.
If no config file is found, acre prints a hint and exits.
acre # launch the TUIThe app opens on the roots screen, listing each configured alias -> path.
| Key | Where | Does |
|---|---|---|
f / m / t |
anywhere | Open the files, marks or stacks screen |
q |
anywhere | Quit |
s |
roots | Scan the root under the cursor and report what changed |
enter / backspace |
files | Open the directory under the cursor, go back up |
r |
files | Move to the next root |
k / c |
files | Cycle the filter by mark, by stack |
space |
files | Select the file under the cursor, or deselect it |
a / x |
files | Put a mark on every file selected, take one off |
s / u |
files | Put them in a stack, take them out of their stacks |
escape |
any screen | Back |
The files screen browses one directory of one root at a time: subdirectories first, with how many files each holds, then the directory's own files. Nothing acts on a directory — marking a whole tree in one keypress is not offered. With nothing selected, the four operations are aimed at the file under the cursor. A directory's files are read a page at a time, so one holding hundreds of thousands draws at once and reads the rest as the cursor moves down.
A scan inserts files the root has and the index lacks, rewrites those whose size or mtime moved, and drops those the root no longer has. Marks and stacks come into being by being applied, and are deleted once nothing carries or sits in them.
Acervus follows the GLIMPSE layered architecture, with import boundaries
enforced by import-linter:
| Layer | Responsibility |
|---|---|
| pacts | Protocols, DTOs, dataclasses, exceptions (no dependencies). |
| specs | Pure business invariants, for mills only. |
| mills | Pure business logic; takes dependencies via constructor. |
| links | Data access — SQLAlchemy models, engine, repositories. |
| gates | Entry points — the Textual TUI. |
| inits | Config loading, dependency injection, the main() entry point. |
| edges | Infrastructure boundary. |
Only inits may wire links and gates together, and specs is reachable from
mills alone. See CLAUDE.md for the full layer rules and
conventions.
src/acervus/
pacts/ # protocols, DTOs, exceptions, AcervusConfig
specs/ # business invariants
mills/ # business logic
links/db/sqlalchemy/ # models, engine, repositories, transaction
links/fs/pathlib/ # the pathlib filesystem reader
gates/tui/textual/ # the Textual app and its screens
inits/ # config loading, DI, the entry point
edges/ # infrastructure
Tasks come from mise.toml and the shared config it includes over
git. mise tasks ls --all lists them.
mise run test:py # all tests
mise run test:unit # unit tests only
mise run test:int # integration tests only
mise run lint:py # every linter at once — what CI runs
mise run lint:ruff # ruff, --no-fix
mise run lint:mypy # mypy src (strict)
mise run lint:import-linter # GLIMPSE layer contracts
mise run lint:pylint src
mise run lint:codespell
mise run format:py # black, ruff --fix and taploPass extra arguments through with --, which is how you run a single test:
mise run test:unit -- -k test_it_trims_surrounding_whitespaceTooling: Black (line length 88, preview), Ruff (select = ["ALL"],
preview), MyPy (strict), Import Linter, Pylint, Codespell. Unit
tests mirror src/ and stay IO-free; integration tests exercise links, gates
and the entry point against real infrastructure.
BSD 3-Clause. See LICENSE.