SyncShelf
by ryansull
README.md
# SyncShelf
SyncShelf is a self-hosted Python service for a two-person household. It is:
- a **remote MCP server** that Claude connects to over OAuth, with tools that answer
reading-history questions, edit the reading log, run and inspect the syncs, and request
books from Chaptarr;
- a set of **scheduled one-way syncs** that keep [Hardcover](https://hardcover.app) as the single
reading log, fed by Goodreads and Audiobookshelf;
- a **Chaptarr client** that adds and searches one book in one format on request.
The build spec is [`docs/SPEC.md`](docs/SPEC.md).
## Status
**Phase 1 (skeleton), released as v0.1.0.** The package, CI and test harness exist.
`uv run python -m syncshelf` starts the service on `0.0.0.0:8000` (set by `server.listen`),
with an MCP endpoint that has no tools yet and a `/healthz` liveness check. Its data lives in
`/data`, or the existing directory named by `SYNCSHELF_DATA_DIR`. Phases are listed in SPEC §3.
## Development
You need [uv](https://docs.astral.sh/uv/). It installs Python 3.14 for you.
```sh
uv sync # create .venv with the locked dependencies
uv run pre-commit install # gitleaks and the private denylist on every commit
```
| What | Command |
|---|---|
| Lint and format | `uv run ruff check .` and `uv run ruff format --check .` |
| Types | `uv run pyright` |
| Tests (offline, as CI runs them) | `uv run pytest` |
| Live tests (opt-in, never in CI) | `SYNCSHELF_LIVE=1 uv run pytest --live` |
| All pre-commit hooks | `uv run pre-commit run --all-files` |
Tests block outbound network access. Live tests read credentials from the git-ignored
repo-root `.env`; see `.env.example`.
To stop real values (your domain, emails, LAN hostnames) ever being committed, list them one per
line in the git-ignored `local/denylist.txt`. The pre-commit hook then refuses any commit that
contains one.
Contributing rules, for people and agents alike, are in [`AGENTS.md`](AGENTS.md) and SPEC §2.10.
## Configuration and deployment
Configuration goes in `profiles.yaml` (git-ignored), modelled on
[`profiles.example.yaml`](profiles.example.yaml). SyncShelf reads it from the path in
`SYNCSHELF_CONFIG`, default `/config/profiles.yaml`. Secrets never go in the file: each is a
`${VAR}` reference to the environment (see `.env.example`) or a `*_file` path. Startup stops on
bad config with a message naming the key. The full deployment guide is `docs/runbook.md`, which
arrives in phase 7. Until then, this section carries the deployment notes.
Each release tag publishes the image `ghcr.io/<owner>/syncshelf:<version>` (and `:latest`)
for amd64 and arm64, built from the [`Dockerfile`](Dockerfile). To run it:
- **`/data`** holds the database, its `pre-migration-NNNN.db` snapshots (taken before each
upgrade, none on a fresh install) and `goodreads/`. `SYNCSHELF_DATA_DIR` names a different
directory, which is useful outside a container. The container runs as `10001:10001`, so a
bind-mounted host directory must exist and be owned by that user
(`chown 10001:10001 <dir>`), or startup stops naming the unwritable path. A named volume
gets the right owner from the image. Startup also stops if the database is newer than the
image (an older release started on it).
- **`/config/profiles.yaml`** is the config. Mount it read-only (`:ro`); SyncShelf never
writes it. Pass the secrets it references as environment variables or `*_file` paths.
- **Listening.** The service listens on `server.listen`, default `0.0.0.0:8000`, so Traefik or
a published port (`-p 8000:8000`) reaches it. Keep port 8000 in the container: the image's
health check probes `127.0.0.1:8000`. Phase 1 has no auth, so publish the port on loopback
only (`-p 127.0.0.1:8000:8000`), as below.
```sh
docker run -d --name syncshelf \
-p 127.0.0.1:8000:8000 \
-v <data-dir>:/data \
-v <config-dir>/profiles.yaml:/config/profiles.yaml:ro \
ghcr.io/<owner>/syncshelf:<version>
```
## Licence
[Apache-2.0](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessResponsive