Skip to content

Repository files navigation

claude-code-sync

Your Claude Code state — sessions, memory, skills, commands, hooks, MCP servers, plugins and settings — kept in sync across every machine you work on, through a private git repository of your own.

Work on your desktop, close the session, open the same session on your laptop and carry on from the same place. Install an MCP server or a skill here, and it shows up everywhere. Different operating systems, unrelated paths, no always-on machine required.

Русская версия · Adding another machine

One conversation continuing on another machine


⚠️ Do not use this repository directly

This repository is a starting point, not a service. If several people pushed into it, their sessions and memory would end up mixed together in one place — and public.

Press “Use this template” → set visibility to Private, and work with the repository you get. It starts with a clean history and belongs only to you.

Do not fork it either. A fork of a public repository on GitHub is always public, and cannot be made private — your transcripts, memory and settings would be readable by anyone. “Use this template” is the button that gives you a private copy.

Pull requests to this repository are not accepted; see CONTRIBUTING.md. Bug reports and questions are welcome in Issues.


What gets synced

Sessions Transcripts, so /resume on another machine continues the same conversation
Memory Fact files, each scoped to one machine, one OS, or all of them
Tools skills/, commands/, hooks/, plans/ — symlinked into the vault
MCP servers Rendered from a template, with per-machine scopes and secrets stripped
Plugins The list, so a new machine tells you what to install
Settings settings.json, merged rather than overwritten
Host files Scripts from ~/.local/bin and systemd units — only the ones you list
Single files CLAUDE.md, statusline.py and any of your own you list in tools/copied-files.json
Secrets Files with API keys you register — encrypted with age, only your machines can decrypt them

Never synced, deliberately: .credentials.json (OAuth tokens — on macOS they live in Keychain anyway), the machine passport, your age private key, plugin caches, shell snapshots and other machine-local state. Secrets travel only in encrypted form — never as plain text.

How it works

Paths are tokenized. The same project sits at unrelated paths on different machines, and one cannot be computed from another:

linux-desktop   /home/alex/projects/MyApp
mac-laptop      /Users/alex/My Projects/Android/Compose/MyApp
win11-pc        D:\Projects\Android\MyApp

So on the way out paths collapse into {{P:myapp}}/app/build.gradle and {{HOME}}/.claude/…, and on the way in they expand for the current machine — right separators, right drive letter. The mapping “project key → path on each machine” lives in project-map/, one file per machine.

Registries are sharded per machine. machines/<machine>.json and project-map/<machine>.json are each owned by exactly one machine. That is what keeps two machines that worked offline from meeting in a merge conflict.

Memory has scopes. A fact about your job is true everywhere; a fact about the graphics driver on one laptop is not. Each fact carries scope in its frontmatter (global, a machine id, os:linux, a list, or !machine for “everywhere except”), and every machine gets a MEMORY.md rendered for itself: what applies here, and — listed separately, not to be applied — what belongs to the others. MCP servers use the same scoping.

Two different mechanisms, on purpose. Skills, commands, hooks, plans and memory files are things you edit, so they become symlinks into the vault: an edit is already in git. settings.json, MCP servers and the plugin list are rewritten by Claude Code itself at runtime, so a symlink there is dangerous — they are rendered from templates on every pull, and settings.json is merged three-way with your local file winning any conflict. Your model, theme and plugins survive.

Secrets never enter the repository as plain text. A value under a key that looks like a token becomes {{ENV:NAME}} in the MCP template, and is filled back in from ~/.claude/ccsync-secrets.env, which is local and git-ignored. If a secret is missing on a machine, the server is installed without it and you are told which variables to add. Files you explicitly register as secrets — that .env file itself, a key file — travel encrypted with age, so every machine gets them without you copying keys by hand (see Secrets). And keys that slip into a conversation are masked in the transcript before it is pushed (see Privacy).

A project that is not bound here still works. Its sessions are laid out under ~/claude-sessions/<key> — they open and read fine, there are simply no project files next to them. Bind the project later with /sync-bind and the old layout is cleaned up on the next pull, but only once nothing is left in it that the new copy does not already have.

Why not just a synced folder

Dropbox, iCloud, a synced folder. Claude Code writes to the transcript after every reply, so two machines produce a steady stream of conflicts — and such folders resolve them by last-writer-wins, which quietly loses turns. That is the smaller problem. The bigger one is that a transcript is full of absolute paths from the machine that wrote it; copying the bytes does not make them valid anywhere else.

rsync or scp by hand. Same path problem, plus you have to remember what to copy and when, in both directions, with no history and nothing to roll back to.

A dotfiles repository. Solves settings and skills, and stops there: no sessions, no path rewriting, and no way to say “this fact is about the laptop only” — machine-specific notes spread to every machine and mislead the agent.

Remote control into the other machine. Requires that machine to be running and reachable. This is the opposite trade: everything is asynchronous, and the machine you left can be shut down, reinstalled, or on a plane.

Just git over ~/.claude. It is hundreds of megabytes of live state — plugin caches, shell snapshots, credentials — rewritten while you work, with absolute paths baked into settings.json and the MCP config. That repository conflicts on every pull and leaks secrets on the first push.

What leaves your machine, and what never does

Everything lives in your own private repository; nothing is sent anywhere else, and the engine talks to no service but your git remote.

Synced Never synced
Session transcripts, with paths tokenized and known keys masked .credentials.json and OAuth tokens (on macOS they are in Keychain anyway)
Memory facts, each scoped ccsync-machine.json — this machine's identity
Skills, commands, hooks, plans ccsync-age.key — the private key that decrypts your secrets
MCP definitions, secrets replaced by {{ENV:NAME}} Plain-text secrets: ccsync-secrets.env itself is git-ignored
The plugin list and merged settings.json Plugin caches, shell snapshots, history.jsonl
Registered secret files — age-encrypted only Anything you mark with /sync-ignore

Two things worth knowing before you trust it with real work. A transcript that has already been pushed is removed by /sync-forget, but that is an ordinary commit — git history is not rewritten, so clones made earlier still hold the old commits. And the Stop hook pushes every few minutes, so /sync-ignore has to be set early to be of any use.

Requirements

  • Claude Code, on close versions across your machines — the transcript format changes between releases
  • git and Python 3 (3.9+); no third-party packages, the engine is stdlib only
  • age — only if you want secrets to travel (pacman -S age, apt install age, brew install age)
  • a private git repository of your own (GitHub, GitLab, your own server)

Getting started (first machine)

  1. Use this template → Private. Do not skip the private part.

  2. Clone it, and clone it to this exact path — the slash commands reference it:

    git clone <your repository url> ~/claude-code-sync
  3. Create this machine's passport. It suggests an id; pick something you will recognise later, such as linux-desktop or win11-laptop:

    python3 ~/claude-code-sync/bin/ccsync.py init
  4. Adopt what you already have. This moves your existing skills/, commands/, hooks/ and plans/ into the vault (with a backup in ~/.claude/backups/) and replaces them with symlinks, and copies your memory files into memory/facts/:

    python3 ~/claude-code-sync/bin/ccsync.py adopt

    Do not skip this step on your first machine. Memory is pushed from the vault, and the symlink that puts it there is only created once memory/facts is non-empty — adopt is what closes that circle.

  5. Send it all up:

    python3 ~/claude-code-sync/bin/ccsync.py push all
  6. Install the hooks, then push them too. The installer only adds hook entries to your settings.json and leaves everything else alone; push then folds the machine-specific paths into {{PYTHON}} and {{VAULT}}, so the other machines get them expanded for themselves:

    python3 ~/claude-code-sync/setup-hooks.py
    python3 ~/claude-code-sync/bin/ccsync.py push tools
  7. Paste the block from docs/claude-md-block.md into your ~/.claude/CLAUDE.md, and ask Claude to give the adopted facts their scope, index_title and index_hook fields.

  8. Restart Claude Code and check that a session starts with a [ccsync] Machine: … line.

Adding your second and further machines: BOOTSTRAP.md — it is a prompt you paste into Claude Code on the new machine. If Claude Code was already used there, read A machine that already had Claude Code first — its own memory, skills and MCP servers get sorted out before anything leaves it. That is what it looks like there: one command, and the machine has your skills, your memory and yesterday's session.

Pulling everything onto a fresh machine

Recorded on one host with two isolated $HOMEs — the scenario lives in demo/ and every line in the frame is real engine output.

Day to day

Hooks do the work: SessionStart pulls and tells Claude which machine it is on, Stop pushes the transcript in the background (debounced, every five minutes), SessionEnd pushes everything. The commands are there for when you want control:

Command
/sync-push [all|session|tools|memory] send this machine's state
/sync-pull [all|session|tools|memory] receive and lay it out here
/sync-status what differs, without changing anything
/sync-bind <key> [path] bind a project to its path here
/sync-mcp [name] [--here|--not-here|--global] MCP servers and their scopes
/sync-host [add <path>] [<key>] [--here|--not-here|--global] host scripts and systemd units
/sync-secrets [add <path> | add-recipient] secrets that travel encrypted, and who can decrypt them
/sync-ignore [reason] keep this session out of the vault
/sync-forget [id] forget a session everywhere (irreversible)

A machine that fell behind rolls nothing back

Every piece of tooling — settings.json, MCP servers, the plugin list, CLAUDE.md and other copied files, host files — keeps a snapshot on each machine: the point where it last agreed with the vault. push sends only what changed here since then. What changed only in the vault stays as it is, even if this machine has not pulled for weeks; a key changed on both sides goes out from here, the same rule pull follows. Before the first snapshot exists (the first sync with this engine), nothing that differs from the vault is sent.

It holds the other way too: pull does not overwrite what you edited here — a file, a setting, an MCP server — and says so.

Whatever push leaves out, it names: not sent — the vault has a different version… That is not an error: the vault is newer, and pull tools catches up. A vault file that no longer parses is never taken for "no file yet" — push leaves it alone and asks you to fix it by hand.

To see in advance what would go or come, add --dry-run: the real command runs on a throwaway copy of the vault and changes nothing, here or there.

python3 ~/claude-code-sync/bin/ccsync.py push all --dry-run
python3 ~/claude-code-sync/bin/ccsync.py pull all --dry-run

Privacy

Your vault is private, but two things are worth knowing.

A session you never want to leave the machine is /sync-ignore, and the mark has to go on early: the background hook sends the transcript every few minutes, so anything said before the mark is already in the vault.

A whole project is /sync-ignore --project <path> --project-wide. You need this where sessions are created on a schedule: every run of a cron job is a new session, so marking them one by one is pointless. Undo it by the project key: --undo <key>.

Empty sessions never leave at all. The stubs created by the claude.ai bridge, and sessions where only a slash command was pressed, are filtered out on push: there is nothing in them, yet they still take up room in the history.

Keys that slip into a conversation — pasted into the chat, shown in the output of cat — are masked in the transcript before it is pushed. Exact matches against the secrets this machine knows (ccsync-secrets.env, registered secret files, the age key) come first; common key shapes (sk-…, ghp_…, ya29.…, JWTs and others) catch the rest. They become {{SECRET:label}}. The masking is one-way: pull does not restore them, a transcript is history, not a working config. Your local transcript is left as it was.

Something that already left is /sync-forget. It deletes the copy in the vault, leaves a tombstone so the other machines drop theirs on the next pull, and removes the local transcript. It does this with an ordinary commit — git history is not rewritten, so clones made earlier still hold the old commits.

Host files: scripts and systemd units

Some of what serves Claude Code lives outside ~/.claude — a script in ~/.local/bin, a timer in ~/.config/systemd/user. The vault carries those too, but only the ones you list: these directories are shared with the rest of the machine's life, which has no business in a synced repository.

/sync-host add ~/.local/bin/my-script.sh   # take it under sync
/sync-host                                  # what travels, and where it applies

The default scope is the current OS rather than "everywhere" — host files are almost always tied to their system. On pull scripts get their x bit back (git does not carry it), and units with an [Install] section are enabled for you. The systemd category only ever applies on Linux: macOS schedules through launchd, Windows through Task Scheduler.

A file you edited in place is not overwritten — the vault keeps a snapshot of what last arrived, and anything that diverged from it is left alone.

Single files in ~/.claude

CLAUDE.md and statusline.py are copied both ways as they are. To carry more files of the same kind — another status line script, an extra settings file for a wrapper — list their names in tools/copied-files.json in your vault:

["my-statusline.py", "wrapper-settings.json"]

Only plain file names directly in ~/.claude are accepted. These files are copied byte for byte, without path rewriting — so no absolute paths and no secrets inside.

An edit made here is protected in both directions: pull does not overwrite a file that diverged from the last synced snapshot (it tells you, and the file goes out with your next push), and push does not send an untouched copy over a newer version another machine has already pushed.

Secrets: encrypted with age

API keys are needed on every machine, and carrying them by hand is tedious. The vault can carry them for you — only encrypted, with age:

/sync-secrets add ~/.claude/ccsync-secrets.env   # take a file under sync
/sync-secrets                                     # what travels, and who can decrypt it

Each registered file is stored as tools/secrets/<path>.age, encrypted for every public key in tools/secrets/recipients.txt, and decrypted to its place on pull. Encryption and decryption run inside push tools and pull tools — there is nothing else to call.

The private key ~/.claude/ccsync-age.key never enters git. On a new machine, generate its own key and add it as a recipient, then re-encrypt from a machine that can already decrypt:

# on the new machine
age-keygen -o ~/.claude/ccsync-age.key && chmod 600 ~/.claude/ccsync-age.key
/sync-secrets add-recipient
/sync-push tools                 # sends its public key to the vault

# on a machine that already decrypts
/sync-pull tools
/sync-push tools                 # re-encrypts for every recipient, the new one included

# back on the new machine
/sync-pull tools                 # the secrets land in place

Without a key, pull says the secrets cannot be decrypted and touches nothing. Like single files, secrets are protected in both directions — with a fingerprint of the last synced value (a SHA-256, never the secret itself): a local file you did not touch is updated on pull, one updated right here is left alone and you are told; push encrypts only what changed here, so an untouched stale copy never overwrites a newer key another machine sent. When the recipient list changes, what gets re-encrypted is the vault's current value, not the local copy.

Extras

extras/statusline.py is an optional status line: model, effort level, 5-hour and weekly limit usage with reset times, context fill, session cost. Nothing installs it automatically — copy it to ~/.claude/statusline.py and add to your settings.json:

"statusLine": { "type": "command", "command": "python3 ~/.claude/statusline.py", "padding": 0 }

Branches

Unreachable branches on their own mean nothing: a long session usually has dozens of them — abandoned continuations you walked away from yourself. So the engine does not scan files for branch points. It compares the file before and after a merge and speaks up only when something that was readable stopped being readable — and only from six records up, because a single rewritten turn and somebody else's branch are structurally identical, and only scale tells them apart.

When it does speak up:

python3 ~/claude-code-sync/bin/ccsync.py branches --session <id>   # what the branches are
python3 ~/claude-code-sync/bin/ccsync.py split <id>                # give each its own session

branches lists every branch with its size, the time of its last record and the opening words, so you can recognise the one you want. split moves each branch into a session of its own — after that every one of them opens whole and as itself. The original file is kept in ~/.claude/backups/.

Language

The engine picks its language from CCSYNC_LANG, falling back to your locale (LC_ALL / LC_MESSAGES / LANG) and then to English. English and Russian ship with it:

CCSYNC_LANG=en python3 ~/claude-code-sync/bin/ccsync.py status

Adding your own is a JSON file and nothing else — copy bin/ccsync_lib/locales/en.json to <your-language>.json and translate the values; the keys are the original Russian strings the engine was written in. A string with no translation falls back to that original rather than breaking, and tests/i18n-coverage.py lists whatever is still missing.

Code comments stay in Russian: they are internal, and they do not stand between you and the tool.

Verifying

Three test rigs cover the engine — private sessions, registries diverging between machines, and session keys. Each one builds its own $HOME and its own bare repository, so they never touch your real setup:

tools/tests/run-all.sh

Worth running once on a new machine, to confirm the engine behaves there.

tools/tests/resume-check.sh is the one check that cannot be automated. It puts a code word into a transcript and asks a live Claude Code for it after the session is restored. Every other rig checks files — it arrived, it sits at the right path, the records match — but none of them answers whether the model on the other side actually sees the conversation, and you cannot tell by looking: with an empty context Claude answers just as confidently. --run asks for you, --clean cleans up.

tests/run-all.sh checks the template itself rather than the engine: fresh-start.sh walks two throwaway machines through the whole first-machine flow and back, asserting that nobody's CLAUDE.md, settings or skills got overwritten on the way; i18n-english.sh runs every command with CCSYNC_LANG=en and fails on any Cyrillic left in the output. Both clone the committed state of this repository, so uncommitted edits are invisible to them.

Limitations, honestly

  • Keep Claude Code versions close across machines: the transcript format changes between releases, and an older build may not read a newer session.
  • One session at a time. Working in the same session on two machines simultaneously is not supported. merge=union keeps every record when two copies collide, but the file is whole only on the surface: Claude Code assembles the conversation by walking parentUuid backwards from the last line of the file — verified by running claude --resume, line order wins over timestamps — so one merged branch is read and the other stays in the file unreachable. See Branches below for what the engine does about it.
  • It is git, not realtime. Expect a delay measured in minutes.
  • Transcripts over 50 MB are skipped with a warning rather than silently — GitHub rejects files above 100 MB.
  • Native Windows is supported (hooks call Python directly, JSON is assembled node by node, symlinks fall back to copies), but it has had less mileage than Linux and macOS.

License

MIT — see LICENSE.

About

Sync Claude Code across all your machines — sessions, memory, skills, MCP servers, settings. Git-backed, path-agnostic, any OS.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages