Sync local service directories to private git mirrors (Claude Code flagship).
Project description
syncestra
Back up developer agent state to a private GitHub mirror — using git as the transport.
syncestra syncs local service directories (Claude Code ~/.claude/ is the flagship)
to a private GitHub repo, treating one git branch per service (or per
service/profile). Sessions, skills, agents, commands, MCP configs, CLAUDE.md,
settings, hooks, plugins, and memory all get versioned, restorable, and
syncable across machines — without backing up project code.
It is not a project backup tool, a CI runner, or a secrets manager. The private repo is the security boundary; secrets ride inside it, so private visibility is mandatory and enforced.
v1 ("It Syncs") ships
init,push,pull,status,restore,profile,list,watch(auto-push), andserve(MCP). Private repos only; the PAT is never logged.
Quickstart (fastest path to first push)
# 1. Install (Python 3.11+)
pip install syncestra
# 2. Give syncestra a GitHub identity (derives the remote URL,
# and auto-creates a private repo on first init if it doesn't exist)
export SYNCESTRA_GITHUB_TOKEN=ghp_xxx # repo-scope PAT
cat > ~/.syncestra/config.yaml <<'EOF'
version: 1
github:
github_username: assaad
repo_name: syncestra
profile: personal
services:
claude-code:
source: "~/.claude"
EOF
# 3. Sync
syncestra init claude-code # create local repo + remote branch
syncestra push claude-code # dry-run by default; add --yes to commit
syncestra status claude-code # local/remote parity
That is the whole loop: init once, then push to back up and pull/restore
to recover or move to another machine. Everything below is configuration,
automation, and the agent interface.
What syncs (Claude Code flagship)
| ✅ Backed up | ❌ Excluded |
|---|---|
| sessions, skills, agents, commands | actual project git repos (code lives elsewhere) |
MCP configs, CLAUDE.md, settings.json |
node_modules, caches |
| hooks, plugins, memory | *.log and other noise |
Filtering is driven by exclude globs and .syncignore; push is the single
source of truth for what reaches the remote.
How it works (one paragraph)
Each service maps to a git branch (<service> or <service>/<profile>, e.g.
claude-code/personal). The local clone is the source of truth; the GitHub
repo is the replica. Convergence is last-write-wins keyed by GitHub commit
time — no custom database, no profiles engine. Switching between personal
and work is just changing the branch namespace. This collapses backup, sync,
multi-machine parity, the work/personal split, and agent automation into one
git primitive.
Three repo models are supported; one-repo-branches is the default:
one-repo-branches— one repo, branch per service/profilerepo-per-service— each service gets its own repo (syncestra-<service>)repo-per-profile— repo per profile (syncestra-personal,syncestra-work), branch per service
CLI commands
| Command | What it does | Default safety |
|---|---|---|
syncestra init <service> |
Create the service's local repo and remote branch | non-destructive |
syncestra push <service> |
Sync local dir → remote branch | dry-run; --yes to commit |
syncestra pull <service> |
Sync remote branch → local dir (last-write-wins) | — |
syncestra status [service] |
Show local/remote parity + changed files | read-only |
syncestra restore <service> |
Overwrite local dir from remote HEAD | destructive |
syncestra profile <name> |
Switch active profile (branch namespace) | — |
syncestra list |
List registered services | read-only |
syncestra serve |
Start the MCP server (stdio) for AI agents | — |
syncestra watch <service> |
Auto-push on file change | — |
Add --json to any command for full structured output (same data, machine
form). Humans get tables/colors by default; agents pass --yes for explicit
intent and read --json.
Exit-code contract (frozen 0–6): agents and CI may branch on these.
| Code | Meaning |
|---|---|
| 0 | success (incl. idempotent no-op) |
| 1 | conflict — divergent branches, LWW unsafe |
| 2 | network error — remote unreachable/timeout |
| 3 | corruption — integrity-check fail, bad object |
| 4 | auth — missing/bad/expired PAT or insufficient scope |
| 5 | config — malformed config, public-repo refusal, missing service |
| 6 | not initialized — no local state for service |
For AI agents (MCP interface)
syncestra serve boots a stdio MCP server (FastMCP) that lets an agent run
every core op as a typed tool — no shell parsing. Register it with a runner
that fetches the package on first spawn, so wiring the MCP is installing
the tool:
{
"mcpServers": {
"syncestra": {
"command": "uvx",
"args": ["--from", "syncestra", "syncestra", "serve"],
"env": {
"SYNCESTRA_GITHUB_TOKEN": "ghp_xxx",
"SYNCESTRA_GITHUB_USERNAME": "assaad",
"SYNCESTRA_REPO_NAME": "syncestra",
"SYNCESTRA_SERVICES": "claude-code=~/.claude"
}
}
}
}
(Identity vars derive the remote URL and auto-create a private repo on first
init. An explicit SYNCESTRA_REMOTE_URL overrides identity.)
Exposed tools: syncestra_init, syncestra_status, syncestra_push,
syncestra_pull, syncestra_restore, syncestra_profile, syncestra_list.
Agent contract:
push/restorealways commit — agent intent is explicit; dry-run is a CLI-only human affordance.- Every tool returns a typed envelope
{schema_version, exit_code, result: {…}}rather than raising;exit_codemirrors the frozen contract above. - Boot preflight:
servevalidates the token, remote, and config before booting. A missing token, placeholder remote, or invalid config makes the server exit at spawn (code 4 auth / 5 config) with an actionable stderr message — instead of booting green and failing on every tool call. No configured services is a non-fatal warning.
Install
pip install syncestra # Python 3.11+
See Releases.
Install (dev)
Requires Python 3.11+ and uv.
uv sync # create venv + install dev deps
uv run pytest # run tests
uv run ruff check . # lint
Configure
syncestra reads one config: ~/.syncestra/config.yaml. Two ways to provide
it — file, or environment (env wins per-field when both are set).
File
version: 1
remote:
url: "https://github.com/<owner>/<repo>.git"
auth: "pat"
repo_model: "one-repo-branches" # one-repo-branches | repo-per-service | repo-per-profile
profile: "personal" # active profile (personal | work)
services:
claude-code:
source: "~/.claude"
exclude: ["projects/*/node_modules", "*.log"]
watch:
enabled: true # always-on auto-sync on init (default). false = manual only.
debounce: 3.0 # seconds to coalesce a burst of changes before pushing.
mode: watchman # watchman (persists across reboots) | foreground (manual)
The GitHub PAT is never in the file — export it:
export SYNCESTRA_GITHUB_TOKEN=ghp_xxx # repo-scope PAT; never logged
Derive the remote from a GitHub identity (optional)
Instead of hand-assembling remote.url, give syncestra your GitHub username
and repo name and it derives the URL for you. If the repo does not exist yet,
syncestra creates it as a private repo under your account on the first
init/serve.
github:
github_username: assaad
repo_name: syncestra
auto_create: true # default; set false to keep 404 a hard error
This derives remote.url = https://github.com/assaad/syncestra.git. An explicit
remote.url (file) or SYNCESTRA_REMOTE_URL (env) still wins — identity only
derives when no explicit URL is set. Both fields are required; a partial
identity is a config error (exit 5).
Auto-create only ever calls POST /user/repos (your namespace) with
private=True. If github_username does not match the token's owner,
syncestra raises a config error instead of creating the repo elsewhere. If the
PAT lacks the repo scope (classic PAT) or Administration: write
(fine-grained), creation fails with exit 4 naming the missing scope.
Saved service presets (optional)
When you set a service up again under the same name (new machine, fresh init),
its per-service backup rules would otherwise have to be re-entered by hand.
Author them once as path-agnostic presets and syncestra recalls them
automatically — on every Config.load, a preset overwrites the matching
service's exclude/adapter while leaving the machine-specific source
untouched.
services:
claude-code:
source: "~/.claude" # per-machine path — different on every host
exclude: [] # will be overwritten by the preset below
presets:
claude-code:
exclude: ["projects/*/node_modules", "*.log"]
adapter: "docker-volume" # optional
A preset never carries source — path-agnosticism is enforced by
construction (the presets.<name> block rejects a source key with a config
error, exit 5). The apply is a full overwrite of exclude and adapter
from the preset; omitting adapter in a preset clears any adapter the service
had. A preset for a service name not yet present in services is stored but
inert until you add that service — that is the "recall on next setup"
behavior. Configs without a presets: block behave exactly as before.
Environment (MCP-first, no file needed)
The entire config can ride environment variables, so an MCP server block carries everything — no YAML edit required. Set vars override their file counterparts:
| Env var | Overrides |
|---|---|
SYNCESTRA_REMOTE_URL |
remote.url |
SYNCESTRA_REMOTE_AUTH |
remote.auth (default pat) |
SYNCESTRA_REPO_MODEL |
repo_model |
SYNCESTRA_PROFILE |
profile |
SYNCESTRA_SERVICES |
services — name=source[,name2=source2] (replaces file) |
SYNCESTRA_GITHUB_USERNAME |
github.github_username (derives remote.url; both required) |
SYNCESTRA_REPO_NAME |
github.repo_name (derives remote.url; both required) |
SYNCESTRA_AUTO_CREATE_REPO |
github.auto_create (true/false; default true) |
SYNCESTRA_WATCH_ENABLED |
watch.enabled (true/false; default true) |
SYNCESTRA_WATCH_DEBOUNCE |
watch.debounce (seconds) |
SYNCESTRA_WATCH_MODE |
watch.mode (watchman/foreground) |
When SYNCESTRA_REMOTE_URL is set and no file exists, no placeholder file is
written — env is the config. Setting only one of SYNCESTRA_GITHUB_USERNAME /
SYNCESTRA_REPO_NAME is a config error (exit 5); both are required to derive a
URL. An explicit SYNCESTRA_REMOTE_URL wins over identity.
Identity-first MCP setup
Same MCP block as the agent section above, but using identity vars (derive URL
- auto-create on first init):
{
"mcpServers": {
"syncestra": {
"command": "uvx",
"args": ["--from", "syncestra", "syncestra", "serve"],
"env": {
"SYNCESTRA_GITHUB_TOKEN": "ghp_xxx",
"SYNCESTRA_GITHUB_USERNAME": "assaad",
"SYNCESTRA_REPO_NAME": "syncestra",
"SYNCESTRA_SERVICES": "claude-code=~/.claude"
}
}
}
}
Auto-push (watch)
Keep the mirror up to date automatically as you add skills, MCP configs, or edit
CLAUDE.md.
Via watchman (recommended)
syncestra watch claude-code --install # register the trigger
syncestra watch claude-code # status (default action)
syncestra watch claude-code --uninstall # remove
watchman fires syncestra watch _fire claude-code on every file change; the
fire-handler debounces (default 3s, --debounce N) and runs a push. Filtering
(.syncignore / classifier / exclude) is unchanged — push stays the single
source of truth for what syncs.
Always-on (default on init)
init auto-installs the watchman trigger when watch.enabled is true (the
default), so sync is on across reboots with no extra command. Auto-start is
watchman-only and non-fatal: if watchman isn't installed, init still
succeeds and prints how to install it. To opt out:
- config:
watch: {enabled: false} - env:
SYNCESTRA_WATCH_ENABLED=false - one-shot:
syncestra init claude-code --no-watch
The fire handler now loops until the workdir is clean, so a change written during an in-flight push is re-pushed instead of dropped (closes the v1 gap).
Across reboots: watchman saves triggers to its statefile and reinstates them when its daemon restarts, so "always-on" holds as long as watchman runs as a managed service — the default on macOS (launchd agent) and Linux (systemd).
brew install watchman/ a distro package set this up for you.
Without watchman
pip install 'syncestra[watch]' # installs watchdog
syncestra watch claude-code --foreground
Foreground mode runs an in-process watcher; same debounce, same filter.
Lost-update safety: the flock single-flight coalesces a burst into one push, and the fire handler loops until the workdir is clean — a change written while a push is already in flight is re-pushed on the next pass instead of dropped.
Deploy to a VPS (Terraform → cloud-init → Ansible)
A fresh VPS can come up with OpenClaw, Hermes, and Claude Code installed, running, and already synced, then keep syncing on its own — with no config file on the box.
syncestra has an env-only config path: when the SYNCESTRA_* env vars are
authoritative, Config.load derives the entire config (remote URL from
SYNCESTRA_GITHUB_USERNAME + SYNCESTRA_REPO_NAME, the service map from
SYNCESTRA_SERVICES, profile, repo model, watch settings) and writes no
~/.syncestra/config.yaml. So a deploy just sets environment variables and runs two
commands per service.
| Concern | How |
|---|---|
| Already synced at first boot | cloud-init runs syncestra init <svc> → syncestra pull <svc> per service before the services start |
| Keep syncing — outbound (VPS→repo) | init auto-installs the watchman trigger (built-in, always-on) |
| Keep syncing — inbound (repo→VPS) | nightly systemd timer at 04:00 Europe/London runs push then pull (both self-gating no-ops when unchanged) |
pull already self-gates — it fetches, compares SHAs, and only fast-forwards when the
remote is actually ahead, so "check first, pull only if changed" is the built-in behavior.
Scaffolding (cloud-init, the systemd unit + timer, an Ansible role, an env template) lives
in deploy/ and works with the current release. Enable the inbound timer only for
read-mostly services (e.g. claude-code) — not write-heavy state dirs (openclaw/hermes),
which fast-forward would clobber.
MCP for Claude Code is opt-in, not default. Claude Code can drive syncestra over the CLI (Bash), and the sync is system-level (needs neither Claude Code nor MCP), so the deploy does not auto-register
syncestra serve. To opt in:claude mcp add syncestra -- syncestra serve
The plan to add a syncestra sync command (push-then-pull, conflict-tolerant, honors a
per-service inbound: skip policy) — which the timer will call once shipped — is at
docs/plans/2026-06-16-vps-default-sync-and-deploy.md.
Status & roadmap
v1 ("It Syncs") is implemented: git engine, core ops (init/push/pull/status/
restore/profile/list), the MCP server with boot preflight, file and
environment config, identity-derived remotes with auto-create, saved presets,
secret scanning, and watch auto-push.
Planned for later releases: auth, diff, checkpoint, rewind, doctor,
nuke, export, template, and a monitor TUI. See
docs/planning-artifacts/ for architecture and epics.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file syncestra-1.0.0.tar.gz.
File metadata
- Download URL: syncestra-1.0.0.tar.gz
- Upload date:
- Size: 282.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c9d33c978929661a315647cef322c0e2d59598d5cbb8f50fad45d7bb2bfbcbe
|
|
| MD5 |
4394778b3dc6f58188cd5cf5b215d65f
|
|
| BLAKE2b-256 |
dfd159c037e7984635a273f15204ddeb2ae90d48cf3278c4a394f98261a2f7fd
|
File details
Details for the file syncestra-1.0.0-py3-none-any.whl.
File metadata
- Download URL: syncestra-1.0.0-py3-none-any.whl
- Upload date:
- Size: 104.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae649a6ce525a0e0674dfb24fe724d947ab1de6734f6bc4460db5037374d3544
|
|
| MD5 |
28cda0a15d4b804ed9bd392875f26afb
|
|
| BLAKE2b-256 |
a05e5e4ac0f246fac339e2c3bd072089423b008fc9835f9b351c9bd529aed09f
|