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
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.
| 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.
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.
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.
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.
- 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)
-
Use this template → Private. Do not skip the private part.
-
Clone it, and clone it to this exact path — the slash commands reference it:
git clone <your repository url> ~/claude-code-sync
-
Create this machine's passport. It suggests an id; pick something you will recognise later, such as
linux-desktoporwin11-laptop:python3 ~/claude-code-sync/bin/ccsync.py init -
Adopt what you already have. This moves your existing
skills/,commands/,hooks/andplans/into the vault (with a backup in~/.claude/backups/) and replaces them with symlinks, and copies your memory files intomemory/facts/:python3 ~/claude-code-sync/bin/ccsync.py adoptDo 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/factsis non-empty —adoptis what closes that circle. -
Send it all up:
python3 ~/claude-code-sync/bin/ccsync.py push all -
Install the hooks, then push them too. The installer only adds hook entries to your
settings.jsonand leaves everything else alone;pushthen 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
-
Paste the block from docs/claude-md-block.md into your
~/.claude/CLAUDE.md, and ask Claude to give the adopted facts theirscope,index_titleandindex_hookfields. -
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.
Recorded on one host with two isolated $HOMEs — the scenario lives in
demo/ and every line in the frame is real engine output.
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) |
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-runYour 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.
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 appliesThe 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.
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.
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 itEach 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 placeWithout 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/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 }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 sessionbranches 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/.
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 statusAdding 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.
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.shWorth 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.
- 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=unionkeeps every record when two copies collide, but the file is whole only on the surface: Claude Code assembles the conversation by walkingparentUuidbackwards from the last line of the file — verified by runningclaude --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.
MIT — see LICENSE.
