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.

v1 ("It Syncs") ships init, push, pull, status, restore, profile, list, watch (auto-push), and serve (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/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 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 / 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.


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


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.1.2.tar.gz (290.8 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.1.2-py3-none-any.whl (104.3 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: syncestra-1.1.2.tar.gz
  • Upload date:
  • Size: 290.8 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

Hashes for syncestra-1.1.2.tar.gz
Algorithm Hash digest
SHA256 90db205a24e21fef11db954c3c8cac92c049ffff8f7c7d0af20c999ff174fc63
MD5 cd02faffd45793f101fa11fa9fb25860
BLAKE2b-256 5b40609a242c795d218e7b6ac31e9f917d11ae142648ef8f7dbd8a13aa0ef7d1

See more details on using hashes here.

File details

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

File metadata

  • Download URL: syncestra-1.1.2-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

Hashes for syncestra-1.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 48f49afe120c721fcd4794239a76dc50b83c19a9f12f899f6bf6a9c2409955ab
MD5 0954a72ac4faf110eddb21ad043f77ff
BLAKE2b-256 756fe89b529002115169cf738f5ba1a08b0245aace37c54797a008c2d747fa53

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