Cross-workstation tooling for Claude Code.
Background daemon that keeps ~/.claude/projects/<project-hash>/memory/
in sync across multiple workstations using a private git repository as
transport. A custom git merge driver (claude-memmerge) unions
MEMORY.md section blocks instead of producing line-level conflicts.
Single Go binary; runs on Windows, Linux, and macOS.
- Go 1.23+ to build
git2.x on PATH at runtime (the daemon shells out)- A private git remote you can
git pushto from your terminal — the daemon inherits your normal git credentials (Git Credential Manager on Windows, SSH agent or~/.gitconfigcredential helpers on Linux/macOS). Confirmgit pushworks against the remote before installing. - The same project paths on each PC (we use Claude's project-hash directory names, which are derived from absolute paths). If your repos live at the same drive letter / mount point on every PC, you're set.
Any empty private git repo works. With the GitHub CLI:
gh repo create <you>/claude-sync --private --description "Private Claude Code memory sync"git clone https://github.com/MarimerLLC/claude-utils.git
cd claude-utils
go build -o bin/ ./cmd/...For a release build that stamps the version into the binary:
VERSION=$(git describe --tags --always --dirty)
go build -ldflags "-X github.com/MarimerLLC/claude-utils/internal/version.Override=$VERSION" -o bin/ ./cmd/...A plain go build (no ldflags) still produces a working binary —
claude-memsync version falls back to the VCS revision Go embeds
automatically (e.g. dev+a52d68609b6e).
Both binaries (claude-memsync and claude-memmerge) end up in bin/.
They must live in the same directory — claude-memsync finds the
merge driver as a sibling. Move both to a stable location if you don't
want them tied to the source checkout, e.g.:
- Windows:
C:\Program Files\claude-memsync\(or anywhere on PATH) - Linux/macOS:
~/.local/bin/
claude-memsync init --remote https://github.com/<you>/claude-sync.gitThis:
- Clones the remote into
~/.claudesync/ - Configures the
MEMORY.mdmerge driver in the local git config - Mirrors any existing
~/.claude/projects/<hash>/memory/content into the work-tree - Writes
~/.claudesync/config.json(per-PC; never synced) - Writes
~/.claudesync/.state/manifest.json(per-PC; never synced) - Pushes the seed commit if this is the first PC
On a second or third PC where the remote already has content from another workstation, init handles collisions:
MEMORY.mdpresent on both sides → semantic merge (sections from both PCs are unioned)- Other memory files differing on both sides → mirror copy is preserved
as
<name>.from-remote-<random>for manual review; the local version is taken
claude-memsync install
claude-memsync start
claude-memsync statusinstall registers the daemon to auto-start when you log in:
- Windows: drops
claude-memsync.cmdin%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\. No admin required. (Task Scheduler was investigated; it requires admin even for per-user logon tasks, so the Startup folder is the simpler path.) - Linux: writes a systemd user unit at
~/.config/systemd/user/claude-memsync.serviceand enables it. - macOS: writes a launchd plist at
~/Library/LaunchAgents/claude-memsync.plist.
All three run as your logged-in user, with full access to your credential vault and SSH keys. None require root.
| Command | What it does |
|---|---|
claude-memsync init --remote <url> |
One-time bootstrap. |
claude-memsync install |
Register for auto-start at next logon. |
claude-memsync uninstall |
Remove the auto-start hook. |
claude-memsync start |
Start the daemon now. |
claude-memsync stop |
Stop the running daemon. |
claude-memsync status |
Print running (pid N) or stopped. |
claude-memsync run |
Run in the foreground (for debugging). |
claude-memsync version |
Print version. |
~/.claude/projects/<hash>/memory/ (Claude reads + writes here — authoritative)
│
│ fsnotify watcher, 3s debounce
▼
~/.claudesync/projects/<hash>/memory/ (mirror — git work-tree)
│
│ git add -A, commit, pull --rebase, push
▼
<remote> (private GitHub repo)
│
│ ls-remote check on 1h timer (or after local push);
│ full pull only when remote SHA actually moved
▼
propagate pull-driven changes back to ~/.claude/projects/...
Idle ticks cost a single git ls-remote round-trip — no pull, no push,
no merge driver invocation. The full pull/push cycle runs only when
origin's branch SHA differs from the local refs/remotes/origin/<branch>
or when there are unpushed commits.
- The daemon never edits files in
~/.claude/projects/...while a write is in progress — it copies into the mirror first, then any inbound changes from agit pullare written back atomically. - The custom merge driver is registered in the local repo config and
invoked by git via
.gitattributes(MEMORY.md merge=claude-memory-index). - Conflicts in non-
MEMORY.mdfiles surface as standard git conflict markers in the affected file. Rare in practice because each memory file has a unique filename per topic.
Synced: every file directly under
~/.claude/projects/<project-hash>/memory/ on any PC.
Not synced:
~/.claude/CLAUDE.md(your global instructions)~/.claude/agents/,~/.claude/commands/,~/.claude/skills/~/.claude/sessions/,~/.claude/todos/,~/.claude/cache/,~/.claude/history.jsonl,~/.claude/settings.json, etc.~/.claudesync/config.json— per-PC (paths embed your machine layout)~/.claudesync/.state/manifest.json— per-PC (delete-detection state)~/.claudesync/daemon.pid— runtime state
If you want any of the additional ~/.claude/ content synced, that's a
future enhancement.
Deletes propagate across PCs. The daemon keeps a per-PC manifest
(~/.claudesync/.state/manifest.json) listing which Claude-side memory
files were present at the last successful sync. On each reconcile pass,
a file that's in the mirror, missing from Claude, and was in the
manifest is treated as a user delete and removed from the mirror; the
next push propagates it. A file that's in the mirror, missing from
Claude, but not in the manifest is treated as an inbound new file
from another PC and copied into Claude.
This means:
- Delete a memory while the daemon is running → propagates immediately (watcher catches it).
- Delete a memory while the daemon is stopped → propagates on next startup (manifest-driven reconcile).
- First-ever run on a PC has no manifest, so deletes can't be inferred from prior state. The daemon takes the safe path: never infer deletes, bring everything together. Subsequent runs work normally.
~/.claude/projects/<hash>/memory/ # what Claude reads + writes
├── MEMORY.md
└── <topic>.md ...
~/.claudesync/ # owned by the daemon
├── config.json # per-PC (gitignored)
├── daemon.pid # per-PC (gitignored)
├── .git/ # the sync repo
├── .gitattributes # MEMORY.md merge=claude-memory-index
├── .gitignore # excludes config.json, .state/, etc.
├── .state/manifest.json # per-PC delete-detection state
└── projects/<hash>/memory/... # git work-tree mirror
The daemon shells out to the system git binary, so it uses whatever
auth is configured in your environment:
- HTTPS with Git Credential Manager (
gh auth loginon Windows populates this): zero extra setup. - SSH: ensure
ssh-agentis running for your user session and your key is loaded. On Linux,systemctl --enable-linger <user>keeps the user instance running across logoffs if you want sync activity while not logged in. - PAT in URL (
https://<token>@github.com/...): works but the token ends up in~/.claudesync/.git/config. Not recommended.
Test before installing the daemon:
git -C ~/.claudesync pushIf that works without prompting, the daemon will too.
- Path consistency required: Claude derives the per-project memory
directory name by escaping the project's absolute path. PCs that
open the same repo at different paths (e.g.
C:\src\foovs.D:\dev\foo) will see them as different projects. - No live conflict UI: when the merge driver emits actual conflict
markers (rare; only on overlapping line edits within the same
MEMORY.mdsection body), the file is committed and pushed with markers. The next time you edit on that PC you'll see them; resolve by hand and save. - Auto-start runs while logged in: the daemon stops when the user logs off. Acceptable since memories don't change while you're away. For 24/7 sync, register a system-wide service manually or enable systemd lingering.
- Stop is a hard kill: by design — git operations are atomic per
command, so we don't risk corruption. If a
.git/index.lockis left behind by an unrelated git crash, remove it manually.
| Path | Purpose |
|---|---|
cmd/claude-memsync |
Daemon binary: lifecycle + watcher + sync loop |
cmd/claude-memmerge |
Git custom merge driver for MEMORY.md |
internal/sync |
Mirror, reconcile, watcher loop, manifest |
internal/merge |
Section-block parser + 3-way semantic merge |
internal/gitwt |
Wrapper around the git CLI scoped to the sync work-tree |
internal/config |
Config load/save (~/.claudesync/config.json) |
internal/proc |
Cross-platform helper that hides child-process console windows on Windows |
internal/version |
Version resolution from -ldflags override or embedded VCS info |
Versioning follows SemVer: vMAJOR.MINOR.PATCH.
We're pre-1.0, which means the daemon's behavior, config format, and
on-disk layout may still change between minor versions; bumping the
patch is reserved for fixes that don't change observable behavior.
Bump rules while on 0.x:
| Change | Bump |
|---|---|
| Bug fix, internal refactor, doc update | patch (v0.1.7 → v0.1.8) |
| Behavior change, new feature, default change, new config field | minor (v0.1.x → v0.2.0) |
| Breaking change to config format, on-disk layout, or CLI surface | minor while pre-1.0 — but call it out clearly in the tag message |
After 1.0 the standard SemVer rules kick in (breaking → major, additive → minor, fix → patch). At that point a v2+ would also need a /v2 suffix in the Go module path.
-
Land all changes on
mainand confirm CI is green. -
Create a GitHub Release for the version. Either:
gh release create v0.1.8 \ --title "v0.1.8" \ --notes "- <bullet summary of user-visible changes>" \ --target main
…or use the GitHub UI (Releases → Draft a new release → choose tag
v0.1.8, targetmain, fill in title and notes, Publish). -
Publishing the Release fires
.github/workflows/release.yml, which cross-compiles both binaries for {linux, darwin, windows} × {amd64, arm64}, stamps the version in via-ldflags, packages each target asclaude-utils-v0.1.8-<os>-<arch>.{tar.gz,zip}, generatesSHA256SUMS, and uploads everything to the Release. ~1 minute. -
Watch the run finish:
gh run watch gh release view v0.1.8 # confirms the assets are attached -
To upgrade your own machine, download the appropriate archive from the Release page, extract, and swap the binaries:
claude-memsync stop # extract archive, copy bin/* to your install location claude-memsync start claude-memsync version # confirms the upgrade landed
GitHub's release: published event is occasionally missed — usually
when the Release was created from a pre-existing tag via API. The
workflow listens for both published and released activity types as
a defensive measure, but it can still happen. Two recovery paths:
-
Manual dispatch (preferred): trigger the workflow against the existing tag without touching the Release.
gh workflow run release.yml --field tag=v0.1.8 gh run watch
This re-uses the same build/upload steps and the existing Release picks up the assets.
-
Recreate the Release:
gh release delete v0.1.8 --cleanup-tag=falsethen recreate it. Sometimes nudges GitHub into firing the event properly. Manual dispatch is simpler — prefer it unless you also need to fix release notes.
The same workflow_run invocation works for retroactively building
archives for any tag that doesn't yet have a Release, or for any
Release whose previous build run failed.
For dev iteration, a plain go build -o bin/ ./cmd/... is enough — the
binaries will report a VCS-derived dev+<sha> version. To stamp a
specific version into a local build:
VERSION=$(git describe --tags --always --dirty)
go build -ldflags "-X github.com/MarimerLLC/claude-utils/internal/version.Override=$VERSION" -o bin/ ./cmd/...PowerShell equivalent:
$VERSION = git describe --tags --always --dirty
go build -ldflags "-X github.com/MarimerLLC/claude-utils/internal/version.Override=$VERSION" -o bin\ .\cmd\...internal/version.String() returns the first of:
- The build-time
Overrideset via-ldflags "-X .../internal/version.Override=<v>"(preferred for releases). runtime/debug.BuildInfo.Main.Version— populated automatically when installed viago install <path>@<tag>. Yieldsv0.1.7for tagged installs, or a pseudo-version likev0.0.0-20260508213755-a52d68630b31for untagged commits.vcs.revisionfrom the embedded build settings — yieldsdev+<short-sha>(ordev+<short-sha>-dirtyif the working tree had uncommitted changes at build time).- The literal
dev— only seen if Go embedded no VCS info (e.g. building from outside a git checkout).
This means a plain go build always produces a binary whose version subcommand says something useful — no Makefile or build script required for development.
MIT — see LICENSE.