Metadata-Version: 2.4
Name: syncestra
Version: 1.0.0
Summary: Sync local service directories to private git mirrors (Claude Code flagship).
Project-URL: Homepage, https://github.com/assaads/syncestra
Project-URL: Repository, https://github.com/assaads/syncestra
Project-URL: Issues, https://github.com/assaads/syncestra/issues
Author: Syncestra
License: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Python: >=3.11
Requires-Dist: detect-secrets>=1.5
Requires-Dist: fastmcp>=2.0
Requires-Dist: gitpython>=3.1
Requires-Dist: pydantic>=2.7
Requires-Dist: pygithub>=2.3
Requires-Dist: pyyaml>=6.0
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest-mock; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: watch
Requires-Dist: watchdog>=4.0; extra == 'watch'
Description-Content-Type: text/markdown

# 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)

```bash
# 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:

```json
{
  "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

```bash
pip install syncestra          # Python 3.11+
```

See [Releases](https://github.com/assaads/syncestra/releases).

### Install (dev)

Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).

```bash
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

```yaml
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:

```bash
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`.

```yaml
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.

```yaml
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):

```json
{
  "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)

```bash
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

```bash
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/`](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:
> ```bash
> 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`](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/](docs/planning-artifacts/) for architecture and epics.

## License

MIT
