Skip to main content

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.

Private repos only; the PAT is never logged. Beyond the v1 git-engine sync, syncestra now ships a file-level merge engine (merge/pull), multi-backend storage (GitHub + S3/R2 with age encryption), a LiteLLM hub-render mode (render), and a claude-sync migration path (import-from-claude-sync). See Status & roadmap.


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/profile
  • repo-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 merge <service> File-level merge of remote tree → local dir (union/deep/LWW) writes (backs up losers)
syncestra sync <service> One-shot push-then-pull (what the inbound timer runs)
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 render Render agent defs from a LiteLLM hub to ~/.claude/agents/ writes (secret-gated)
syncestra sync-mcp Union-merge an MCP config (mcpServers) into ~/.claude/ writes (union-merge)
syncestra import-from-claude-sync One-shot import from a claude-sync R2 bucket writes
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_sync, syncestra_restore, syncestra_profile, syncestra_list.

Agent contract:

  • push / restore always 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_code mirrors the frozen contract above.
  • Boot preflight: serve validates 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 servicesname=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.

Foreground watch memory

Foreground mode dispatches each event to a single-worker executor, so a slow push never blocks the watchdog observer thread. Three defenses keep that executor bounded and a latent leak from OOMing the host:

  • Submit-time coalescing — at most one fire() is ever in-flight. Events that arrive while a push is running are dropped at the handler (lost-update safety above still holds — the in-flight push converges changes that arrive during the burst via fire's loop-until-clean, and the next real event re-fires for anything written after). The executor's pending queue therefore stays at ≤ 1 even under a rapid burst.
  • Hot-dir filter — churn under .git/, node_modules/, plugins/cache/, and .claude/projects/ (push-excluded paths) is ignored at the handler and never enters the pipeline.
  • Heartbeat + RSS recycle — a daemon thread logs current RSS and queue depth every --heartbeat seconds (default 60); when current RSS crosses --max-rss-mb (default 1536 MiB), the observer and executor are torn down and restarted fresh in-process so no latent leak can grow unbounded.
syncestra watch claude-code --foreground --max-rss-mb 1024 --heartbeat 30

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 syncestra sync (push-then-pull; self-gating no-op 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 inbound timer runs syncestra sync <svc> — a one-shot push-then-pull that is a self-gating no-op when nothing changed (design notes at docs/plans/2026-06-16-vps-default-sync-and-deploy.md).


Merge engine, backends & hub-render (beyond v1)

v1 was a one-way push to a private GitHub mirror. The current release adds bidirectional, multi-backend sync plus a render mode — all behind the same secret-gate invariant (every write path is scanned; no env switch disables it).

  • Merge engine (src/syncestra/merge/, surfaced via merge and pull). Replaces fast-forward-clobber with real file-level merging: union for additive list files (MEMORY.md, checklists — both sides kept, never silently dropped), deep-merge for structured JSON/YAML config, and last-writer-wins for opaque/binary files. Every mutation is atomic-write + backup + verify-or-restore, so a losing side is quarantined, not lost.
  • Multi-backend storage (src/syncestra/backends/, config backends). The StorageAdapter Protocol now dispatches to GitHub (default) or S3/R2, with optional age at-rest encryption. Switching backends is a config change, not a code change; the one-way push path keeps working unchanged.
  • Hub-render (src/syncestra/render/, the render command). Pulls agent definitions (AgentCards + prompts) from a LiteLLM hub onto disk via per-harness adapters — claude-code, opencode, cursor — with an atomic-swap cache (keeps the last good render on failure) and a secret gate on the rendered bytes. syncestra consumes LiteLLM as a source only; it never becomes a router.
  • claude-sync migration (src/syncestra/migrate/). import-from-claude-sync does a one-shot import from a claude-sync R2 bucket into ~/.claude/; sync-mcp union-merges an mcpServers block into the local layout. These are the parity path for retiring claude-sync.

Status & roadmap

Implemented: the git engine and core ops (init/push/pull/status/ restore/profile/list/watch); the file-level merge engine (merge, sync); multi-backend storage (GitHub + S3/R2) with age encryption; the hub-render mode (render) with claude-code/opencode/cursor adapters; the claude-sync migration path (import-from-claude-sync, sync-mcp); the MCP server (serve) 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

syncestra-1.9.1.tar.gz (565.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

syncestra-1.9.1-py3-none-any.whl (199.2 kB view details)

Uploaded Python 3

File details

Details for the file syncestra-1.9.1.tar.gz.

File metadata

  • Download URL: syncestra-1.9.1.tar.gz
  • Upload date:
  • Size: 565.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.33 {"installer":{"name":"uv","version":"0.11.33","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

Hashes for syncestra-1.9.1.tar.gz
Algorithm Hash digest
SHA256 604000fe49ff8ea95f4ab63afb6a9cec09d434ee169da53436dab7c96f69b027
MD5 976423c426737dd2c26128d420822633
BLAKE2b-256 4c299437c2f9105b8b2dacb01a4f116c662fdf118f5576e1c1e5c9c39fed34f5

See more details on using hashes here.

File details

Details for the file syncestra-1.9.1-py3-none-any.whl.

File metadata

  • Download URL: syncestra-1.9.1-py3-none-any.whl
  • Upload date:
  • Size: 199.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.33 {"installer":{"name":"uv","version":"0.11.33","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

Hashes for syncestra-1.9.1-py3-none-any.whl
Algorithm Hash digest
SHA256 43099cb3ab3d9028513f60b4d4ceceec8bbdc5aa13d777c107e2da57d6662107
MD5 d145061500574eed362ab851b1cd7fbb
BLAKE2b-256 96d3b2be117bedb6a4e9aeafda942be2c33de25b911195029ecb27a088974c8f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page