
AI for Engineering Productivity
Unified Cross-Machine Claude Memory Using Chezmoi
Unified Cross-Machine Claude Memory Using Chezmoi
Unified Cross-Machine Claude Memory Using Chezmoi
Claude’s memory doesn’t clean itself up or follow you between machines. Here’s how I use chezmoi to sync and manage Claude memories everywhere.
·
⏱
5 min read

Max Berman
Software Developer at Leo
Software Developer at Leo
Software Engineer
Software Engineer
Software engineer offering 5+ years of experience in the full software development lifecycle- from concept through delivery of next generation applications and reliable, scalable solutions.

BOTTOM LINE
Claude is an amazing, amazing tool and technology, but I still wouldn’t say it’s completely perfect. One of the biggest places I think people still hold an edge is the ability to see ‘the bigger picture’. I think a lot of that has something to do with the fact that our own ‘context window’ seems to work differently than LLMs. We keep a lot less detail, but we have a much broader cliff notes version of the stuff we recall. Claude Memories is one of Anthropic’s ways of trying to close that gap, giving claude the ability to write itself notes on different topics for future use.
Currently I think this is one of Claude’s biggest gaps: the entire lifecycle around memories, and management of memories across machines. Namely, nothing ever cleans up irrelevant memories (something unfortunately we seem to share, what I wouldn’t give to erase some embarrassing memories from middle school), and nothing syncs to a new machine (why should claude get amnesia just because I’m running a cloud instance).
Let’s say you have your vscode settings in settings.json, but you work on two different machines. You don’t want to have to open machine B and change the setting there as well every time you change a setting on machine A, just so your workspace looks/acts the same. So what do you do? A smart thing to do would be to move settings.json to a git repository, and replace the actual settings.json with a pointer to the one in the git repo. Voilà! Now you manage your settings in one place for both machines and since they both now read a symlink instead of an actual file, they update automatically. Magic! Traditionally this git repo has been called dotfiles, and every dev who works this way keeps their own personal dotfiles. Devs worked this way for a long time, but dotfiles suffered from a handful of serious friction points:
Cross OS dotfiles: what happens when you need different paths, or different behavior when your two machines run different operating systems?
Secrets management: It’s not good security practice to check plain text secrets into a git repository, so how should that be handled?
File Rewrites: a lot of applications are hardcoded to expect an actual configuration file rather than a symlink, and might overwrite your symlink automatically if it’s not a regular file.
In order to fix these issues, developer Tom Payne launched chezmoi as an open source project, with the aim to ship a single source binary that uses a system of sources and targets to let you merge all of your dotfile changes from machine A to machine B with tools for dictating behavior across platforms and handling merge conflicts, diffs, etc. I will wholeheartedly recommend it as one of my favorite CLI tools.
IN PRACTICE
When I started thinking about how I wanted to sync memories across machines, it was therefore natural to turn to chezmoi. Here’s how I did it.
One store for every machine
Claude keeps each project’s memories in ~/.claude/projects/<project-path>/memory, where the folder name is the project’s absolute path with / and . swapped for -. That path is different on every machine (my Mac and my WSL box don’t even agree on where my repos live), so syncing those folders directly doesn’t work.
Instead I keep one store, ~/.claude/memories/<project>/, which is what chezmoi actually manages, and make each project’s memory folder a symlink into it. A chezmoi run_after_ script recreates those symlinks every time I run chezmoi apply, working out the right project path for the machine it’s on (and merging in any memories that already existed there). Claude reads and writes memories exactly like it normally would, but everything lands in ~/.claude/memories, which is the only folder the hooks below need to care about.
Here’s the script. It lives in the root of the chezmoi source dir as run_after_link-claude-memories.sh.tmpl, and the .tmpl suffix lets chezmoi fill in the right dev directory for each OS:
#!/bin/sh # Symlink each Claude Code project memory dir into the synced ~/.claude/memories # store. Project dir names encode the absolute repo path, which differs per # machine, so they are computed here from this machine's dev dir. set -eu {{ if eq .chezmoi.os "darwin" -}} DEV_DIR="$HOME/<mac-dev-dir>" {{ else -}} DEV_DIR="$HOME/<linux-dev-dir>" {{ end -}} # Map a store to its checkout name where this machine names the repo differently. dir_for_store() { case "$1" in # <store-name>) printf '<checkout-name>' ;; *) printf '%s' "$1" ;; esac } MEMORIES_DIR="$HOME/.claude/memories" [ -d "$MEMORIES_DIR" ] || exit 0 for store in "$MEMORIES_DIR"/*/; do store="${store%/}" name=$(basename "$store") # dev-root holds memories for sessions started in the dev dir itself. if [ "$name" = "dev-root" ]; then project_path="$DEV_DIR" else project_path="$DEV_DIR/$(dir_for_store "$name")" fi # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$project_path" | tr '/.' '-')" memory_link="$project_dir/memory" mkdir -p "$project_dir" if [ -e "$memory_link" ] && [ ! -L "$memory_link" ]; then # Merge machine-local memories into the store (no clobber), keep a backup. cp -Rn "$memory_link"/. "$store"/ 2>/dev/null || true mv "$memory_link" "$memory_link.pre-sync.$(date +%s)" fi ln -sfn "$store" "$memory_link" done
#!/bin/sh # Symlink each Claude Code project memory dir into the synced ~/.claude/memories # store. Project dir names encode the absolute repo path, which differs per # machine, so they are computed here from this machine's dev dir. set -eu {{ if eq .chezmoi.os "darwin" -}} DEV_DIR="$HOME/<mac-dev-dir>" {{ else -}} DEV_DIR="$HOME/<linux-dev-dir>" {{ end -}} # Map a store to its checkout name where this machine names the repo differently. dir_for_store() { case "$1" in # <store-name>) printf '<checkout-name>' ;; *) printf '%s' "$1" ;; esac } MEMORIES_DIR="$HOME/.claude/memories" [ -d "$MEMORIES_DIR" ] || exit 0 for store in "$MEMORIES_DIR"/*/; do store="${store%/}" name=$(basename "$store") # dev-root holds memories for sessions started in the dev dir itself. if [ "$name" = "dev-root" ]; then project_path="$DEV_DIR" else project_path="$DEV_DIR/$(dir_for_store "$name")" fi # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$project_path" | tr '/.' '-')" memory_link="$project_dir/memory" mkdir -p "$project_dir" if [ -e "$memory_link" ] && [ ! -L "$memory_link" ]; then # Merge machine-local memories into the store (no clobber), keep a backup. cp -Rn "$memory_link"/. "$store"/ 2>/dev/null || true mv "$memory_link" "$memory_link.pre-sync.$(date +%s)" fi ln -sfn "$store" "$memory_link" done
#!/bin/sh # Symlink each Claude Code project memory dir into the synced ~/.claude/memories # store. Project dir names encode the absolute repo path, which differs per # machine, so they are computed here from this machine's dev dir. set -eu {{ if eq .chezmoi.os "darwin" -}} DEV_DIR="$HOME/<mac-dev-dir>" {{ else -}} DEV_DIR="$HOME/<linux-dev-dir>" {{ end -}} # Map a store to its checkout name where this machine names the repo differently. dir_for_store() { case "$1" in # <store-name>) printf '<checkout-name>' ;; *) printf '%s' "$1" ;; esac } MEMORIES_DIR="$HOME/.claude/memories" [ -d "$MEMORIES_DIR" ] || exit 0 for store in "$MEMORIES_DIR"/*/; do store="${store%/}" name=$(basename "$store") # dev-root holds memories for sessions started in the dev dir itself. if [ "$name" = "dev-root" ]; then project_path="$DEV_DIR" else project_path="$DEV_DIR/$(dir_for_store "$name")" fi # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$project_path" | tr '/.' '-')" memory_link="$project_dir/memory" mkdir -p "$project_dir" if [ -e "$memory_link" ] && [ ! -L "$memory_link" ]; then # Merge machine-local memories into the store (no clobber), keep a backup. cp -Rn "$memory_link"/. "$store"/ 2>/dev/null || true mv "$memory_link" "$memory_link.pre-sync.$(date +%s)" fi ln -sfn "$store" "$memory_link" done
To set this up you’ll need chezmoi and jq installed, your dotfiles repo set up with chezmoi (chezmoi init <github-user>/<dotfiles-repo>), and the store added once with chezmoi add ~/.claude/memories.
When I started thinking about how I wanted to sync memories across machines, it was therefore natural to turn to chezmoi. Here’s how I did it.
One store for every machine
Claude keeps each project’s memories in ~/.claude/projects/<project-path>/memory, where the folder name is the project’s absolute path with / and . swapped for -. That path is different on every machine (my Mac and my WSL box don’t even agree on where my repos live), so syncing those folders directly doesn’t work.
Instead I keep one store, ~/.claude/memories/<project>/, which is what chezmoi actually manages, and make each project’s memory folder a symlink into it. A chezmoi run_after_ script recreates those symlinks every time I run chezmoi apply, working out the right project path for the machine it’s on (and merging in any memories that already existed there). Claude reads and writes memories exactly like it normally would, but everything lands in ~/.claude/memories, which is the only folder the hooks below need to care about.
Here’s the script. It lives in the root of the chezmoi source dir as run_after_link-claude-memories.sh.tmpl, and the .tmpl suffix lets chezmoi fill in the right dev directory for each OS:
#!/bin/sh # Symlink each Claude Code project memory dir into the synced ~/.claude/memories # store. Project dir names encode the absolute repo path, which differs per # machine, so they are computed here from this machine's dev dir. set -eu {{ if eq .chezmoi.os "darwin" -}} DEV_DIR="$HOME/<mac-dev-dir>" {{ else -}} DEV_DIR="$HOME/<linux-dev-dir>" {{ end -}} # Map a store to its checkout name where this machine names the repo differently. dir_for_store() { case "$1" in # <store-name>) printf '<checkout-name>' ;; *) printf '%s' "$1" ;; esac } MEMORIES_DIR="$HOME/.claude/memories" [ -d "$MEMORIES_DIR" ] || exit 0 for store in "$MEMORIES_DIR"/*/; do store="${store%/}" name=$(basename "$store") # dev-root holds memories for sessions started in the dev dir itself. if [ "$name" = "dev-root" ]; then project_path="$DEV_DIR" else project_path="$DEV_DIR/$(dir_for_store "$name")" fi # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$project_path" | tr '/.' '-')" memory_link="$project_dir/memory" mkdir -p "$project_dir" if [ -e "$memory_link" ] && [ ! -L "$memory_link" ]; then # Merge machine-local memories into the store (no clobber), keep a backup. cp -Rn "$memory_link"/. "$store"/ 2>/dev/null || true mv "$memory_link" "$memory_link.pre-sync.$(date +%s)" fi ln -sfn "$store" "$memory_link" done
To set this up you’ll need chezmoi and jq installed, your dotfiles repo set up with chezmoi (chezmoi init <github-user>/<dotfiles-repo>), and the store added once with chezmoi add ~/.claude/memories.
Two hooks manage the sync lifecycle for memories:
The first hook is registered to the ‘PostToolUse’ event on ‘Write|Edit’: It does immediate per-file backup. It fires after every file write or edit and it does the following:
gets the path of the file from the tool call with ‘jq’ and resolves it to its absolute path on the machine
If the file is under ~/.claude/memories, it runs the ‘chezmoi add’ command on the file.
It ends in ‘|| true’, so any crash or failure on the hook doesn’t interfere with the claude session.
The effect is that every new or updated memory automatically lands in the chezmoi/dotfiles source right away. It means it’s already there for when the second hook runs, and it also covers the case where a memory is written but the claude session subsequently crashes for an unrelated reason.
The second hook is registered on Claude’s stop event and does most of the heavy lifting. It fires after every time claude stops responding and runs a script called ‘chezmoi-memories-sync.sh’, which does the following things:
Reconciles the upstream: It does a 3-way comparison between the store, HEAD, and a saved “last synced” commit (~/.claude/.chezmoi-memories-base). Changes made on the other machine are applied to the local store, including deletions. If both sides changed the file, the local copy wins and the path goes to a conflicts file. Without this step, the next step would overwrite the other machine’s changes with stale local files.
Repair MEMORY.md: This file is claude’s ‘index’ of all its memories, although it can find memories anywhere, this is where it actually looks when it’s working and wants to check if it has memories on a given subject. This step treats the index as derived state. It drops lines pointing to deleted files and appends a line for any memory missing from the index, using the file’s
description:frontmatter. Hand-written lines are never rewritten and files that were recently updated are skipped so that you don’t have conflicts on a memory that the agent is currently writing.Mirror: This is the step that actually syncs the changes to the dotfiles repo. It does it like this:
Sweep: Runs ‘chezmoi add ~/.claude/memories’ which is a catch all to grab any changes that didn’t fall under the first hook, and add them to the source tree.
Propagate deletions: ‘chezmoi add’ never removes anything, so this runs ‘chezmoi forget’ on managed entries whose target is gone. Otherwise a deleted memory would just come back on the next chezmoi sync.
Guards: This step exits quietly if the source tree isn’t a git repo, or a rebase/merge/cherry-pick is currently in progress. This keeps any potentially catastrophic merge/rebase situations from being decided or overwritten by the script, leaving it up to the user to resolve manually.
Commit: Stages and commits only
private_dot_claude/memories, so any other dotfile changes you have in progress in the chezmoi repo never get swept into an auto-sync commit.Push: Pushes to origin. On success it clears the “unpushed” sentinel and reports any conflicts/issues it ran into through a systemMessage. On failure, the commit is still in the repo locally, it’s just not pushed to the remote. It writes a sentinel file and shows a systemMessage with the suggested fix.
Together these two hooks manage the lifecycle for memory sync itself. They never leave the repo stuck in a mid-rebase state or add chezmoi-managed files that aren’t explicitly part of claude’s memory system, and they never crash the current claude session on failures.
One thing the hooks don’t do is pull. The reconcile step compares against the local chezmoi repo, so changes from another machine (or a cloud session) only show up once that repo has them. Running chezmoi update pulls and applies them, and the hooks take it from there. You could add chezmoi update to your ~/.zshrc (or your shell of choice’s startup file) so it happens automatically, but for now I prefer to run it manually when I sit down at a machine.
Here are the hooks. They go under the "hooks" key in ~/.claude/settings.json, and the Stop hook expects the script below saved as ~/.claude/hooks/chezmoi-memories-sync.sh and made executable:
{ "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "jq -r '.tool_response.filePath // .tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true", "timeout": 20, "statusMessage": "Backing up memory to chezmoi" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/chezmoi-memories-sync.sh", "timeout": 60, "async": true, "statusMessage": "Syncing memories to chezmoi" } ] } ] }
{ "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "jq -r '.tool_response.filePath // .tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true", "timeout": 20, "statusMessage": "Backing up memory to chezmoi" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/chezmoi-memories-sync.sh", "timeout": 60, "async": true, "statusMessage": "Syncing memories to chezmoi" } ] } ] }
{ "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "jq -r '.tool_response.filePath // .tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true", "timeout": 20, "statusMessage": "Backing up memory to chezmoi" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/chezmoi-memories-sync.sh", "timeout": 60, "async": true, "statusMessage": "Syncing memories to chezmoi" } ] } ] }
Here’s the script:
#!/usr/bin/env bash # Back the Claude memory store up to chezmoi, then commit and push it. # Runs from the Stop hook. set -uo pipefail SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" PATHSPEC="private_dot_claude/memories" SENTINEL="$HOME/.claude/.chezmoi-memories-unpushed" # Discard the hook's stdin JSON. [ -t 0 ] || cat >/dev/null 2>&1 git_c() { git -C "$SRC" "$@"; } report_unpushed() { local n="$1" printf '%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ) — $n commit(s) unpushed" > "$SENTINEL" printf '{"systemMessage":"chezmoi memories: %s commit(s) committed locally but NOT pushed. Resolve with: git -C ~/.local/share/chezmoi pull --rebase \\u0026\\u0026 git -C ~/.local/share/chezmoi push"}\n' "$n" } unpushed_count() { git_c rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?" } store_healthy() { [ -d "$STORE" ] && [ -n "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Step 1: Pull upstream memory changes into the local store --------------- # Three-way comparison against the commit of the last completed sync. BASEFILE="$HOME/.claude/.chezmoi-memories-base" CONFLICTS="$HOME/.claude/.chezmoi-memories-conflicts" blob_at() { git_c rev-parse -q --verify "$1:$2" 2>/dev/null || true; } # Tree hash the store would have if committed. store_tree() { local idx idx="$(mktemp)" || return 1 rm -f "$idx" GIT_INDEX_FILE="$idx" git_c --work-tree="$STORE" add -- '*.md' 2>/dev/null && GIT_INDEX_FILE="$idx" git_c write-tree rm -f "$idx" } # First run: use the newest commit whose memories tree matches the store. bootstrap_base() { local tree c tree="$(store_tree)" || return 0 git_c log --format=%H -n 200 -- "$PATHSPEC" | while IFS= read -r c; do if [ "$(git_c rev-parse -q --verify "$c:$PATHSPEC")" = "$tree" ]; then printf '%s\n' "$c" break fi done } reconcile_upstream() { local base head src rel tgt b h t base="$(cat "$BASEFILE" 2>/dev/null || bootstrap_base)" head="$(git_c rev-parse -q --verify HEAD)" || return 0 [ -n "$base" ] && [ "$base" != "$head" ] || return 0 git_c cat-file -e "$base^{commit}" 2>/dev/null || return 0 git_c diff --name-only --no-renames "$base" "$head" -- "$PATHSPEC" | while IFS= read -r src; do rel="${src#"$PATHSPEC"/}" tgt="$STORE/$rel" b="$(blob_at "$base" "$src")" h="$(blob_at "$head" "$src")" t="" [ -f "$tgt" ] && t="$(git hash-object --stdin < "$tgt")" # Already up to date. [ "$t" = "$h" ] && continue if [ "$t" = "$b" ]; then # Untouched locally: take the upstream version (or deletion). if [ -z "$h" ]; then rm -f "$tgt" rmdir "$(dirname "$tgt")" 2>/dev/null || true else mkdir -p "$(dirname "$tgt")" git_c cat-file blob "$h" > "$tgt" fi else # Changed on both sides: keep the local copy and record the conflict. printf '%s\n' "$rel" >> "$CONFLICTS" fi done } if store_healthy; then reconcile_upstream fi # --- Step 2: Repair each store's MEMORY.md index ---------------------------- # Drop lines for deleted files; add lines for memories the index misses. # Recently modified files are skipped so their writer can index them. INDEX_GRACE_MIN=10 index_line_target() { printf '%s' "$1" | sed -n 's/^- \[[^]]*\](\([^)]*\.md\)).*/\1/p' } # Drop auto-generated index lines when a hand-written line exists for the same file. drop_superseded_generated_lines() { local buf buf="$(mktemp)" || { cat; return 0; } cat > "$buf" awk ' function target(l, s) { if (!match(l, /^- \[[^]]*\]\([^)]*\.md\)/)) return "" s = substr(l, RSTART, RLENGTH) sub(/^- \[[^]]*\]\(/, "", s); sub(/\)$/, "", s) return s } function generated(l, t, title) { title = t; sub(/\.md$/, "", title); gsub(/-/, " ", title) return index(l, "- [" title "](" t ")") == 1 } NR == FNR { t = target($0) if (t != "" && !generated($0, t)) handwritten[t] = 1 next } { t = target($0) if (t != "" && handwritten[t] && generated($0, t)) next print }' "$buf" "$buf" rm -f "$buf" } sync_memory_index() { local dir="$1" local index="$dir/MEMORY.md" local tmp tmp="$(mktemp)" || return 0 [ -f "$index" ] || printf '# Memory Index\n\n' > "$index" # Drop lines whose file no longer exists. while IFS= read -r line || [ -n "$line" ]; do local target target="$(index_line_target "$line")" if [ -n "$target" ] && [ ! -f "$dir/$target" ]; then continue fi printf '%s\n' "$line" done < "$index" | drop_superseded_generated_lines > "$tmp" # Add a line for every unindexed memory, using its `description:` frontmatter. local f base desc title for f in "$dir"/*.md; do [ -e "$f" ] || continue base="$(basename "$f")" [ "$base" = "MEMORY.md" ] && continue grep -qF "($base)" "$tmp" && continue [ -n "$(find "$f" -mmin -"$INDEX_GRACE_MIN" 2>/dev/null)" ] && continue desc="$(sed -n 's/^description:[[:space:]]*//p' "$f" | head -1 \ | sed 's/^"//; s/"$//; s/\\"/"/g')" title="$(printf '%s' "${base%.md}" | tr '-' ' ')" [ -n "$desc" ] || desc="$title" printf -- '- [%s](%s) — %s\n' "$title" "$base" "$desc" >> "$tmp" done if ! cmp -s "$tmp" "$index"; then cat "$tmp" > "$index" fi rm -f "$tmp" } if store_healthy; then for memdir in "$STORE"/*/; do [ -d "$memdir" ] || continue sync_memory_index "${memdir%/}" done fi # --- Step 3: Mirror the store into chezmoi's source tree -------------------- chezmoi add "$STORE" 2>/dev/null || true # --- Step 4: Propagate deletions and renames -------------------------------- # `chezmoi add` never removes files, so forget entries whose target is gone. # Skipped if the store looks empty, so a broken store can't wipe the backup. if store_healthy; then chezmoi managed "$STORE" 2>/dev/null | sort -r | while IFS= read -r rel; do [ -n "$rel" ] || continue [ -e "$HOME/$rel" ] || chezmoi forget --force "$HOME/$rel" >/dev/null 2>&1 || true done fi # --- Step 5: Guards --------------------------------------------------------- [ -d "$SRC/.git" ] || exit 0 # Don't commit in the middle of a rebase, merge, etc. for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do if [ -e "$SRC/.git/$state" ]; then exit 0 fi done # --- Step 6: Commit (memories path only) ------------------------------------ git_c add -- "$PATHSPEC" 2>/dev/null || exit 0 if ! git_c diff --cached --quiet -- "$PATHSPEC" 2>/dev/null; then git_c commit -q -m "memories: auto-sync $(date +%Y-%m-%dT%H:%M)" -- "$PATHSPEC" 2>/dev/null || exit 0 fi # Record this commit as the base for the next sync. git_c rev-parse HEAD > "$BASEFILE" 2>/dev/null || true # --- Step 7: Push ----------------------------------------------------------- if git_c push -q origin HEAD 2>/dev/null; then rm -f "$SENTINEL" if [ -s "$CONFLICTS" ]; then printf '{"systemMessage":"chezmoi memories: %s memory file(s) changed on another machine AND locally; kept the local copy. Review the list in ~/.claude/.chezmoi-memories-conflicts (git log shows the upstream version), then delete that file."}\n' \ "$(sort -u "$CONFLICTS" | wc -l | tr -d ' ')" fi exit 0 fi # Push failed: the commit is safe locally; report the backlog. n="$(unpushed_count)" [ "$n" = "0" ] && exit 0 report_unpushed "$n" exit 0
#!/usr/bin/env bash # Back the Claude memory store up to chezmoi, then commit and push it. # Runs from the Stop hook. set -uo pipefail SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" PATHSPEC="private_dot_claude/memories" SENTINEL="$HOME/.claude/.chezmoi-memories-unpushed" # Discard the hook's stdin JSON. [ -t 0 ] || cat >/dev/null 2>&1 git_c() { git -C "$SRC" "$@"; } report_unpushed() { local n="$1" printf '%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ) — $n commit(s) unpushed" > "$SENTINEL" printf '{"systemMessage":"chezmoi memories: %s commit(s) committed locally but NOT pushed. Resolve with: git -C ~/.local/share/chezmoi pull --rebase \\u0026\\u0026 git -C ~/.local/share/chezmoi push"}\n' "$n" } unpushed_count() { git_c rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?" } store_healthy() { [ -d "$STORE" ] && [ -n "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Step 1: Pull upstream memory changes into the local store --------------- # Three-way comparison against the commit of the last completed sync. BASEFILE="$HOME/.claude/.chezmoi-memories-base" CONFLICTS="$HOME/.claude/.chezmoi-memories-conflicts" blob_at() { git_c rev-parse -q --verify "$1:$2" 2>/dev/null || true; } # Tree hash the store would have if committed. store_tree() { local idx idx="$(mktemp)" || return 1 rm -f "$idx" GIT_INDEX_FILE="$idx" git_c --work-tree="$STORE" add -- '*.md' 2>/dev/null && GIT_INDEX_FILE="$idx" git_c write-tree rm -f "$idx" } # First run: use the newest commit whose memories tree matches the store. bootstrap_base() { local tree c tree="$(store_tree)" || return 0 git_c log --format=%H -n 200 -- "$PATHSPEC" | while IFS= read -r c; do if [ "$(git_c rev-parse -q --verify "$c:$PATHSPEC")" = "$tree" ]; then printf '%s\n' "$c" break fi done } reconcile_upstream() { local base head src rel tgt b h t base="$(cat "$BASEFILE" 2>/dev/null || bootstrap_base)" head="$(git_c rev-parse -q --verify HEAD)" || return 0 [ -n "$base" ] && [ "$base" != "$head" ] || return 0 git_c cat-file -e "$base^{commit}" 2>/dev/null || return 0 git_c diff --name-only --no-renames "$base" "$head" -- "$PATHSPEC" | while IFS= read -r src; do rel="${src#"$PATHSPEC"/}" tgt="$STORE/$rel" b="$(blob_at "$base" "$src")" h="$(blob_at "$head" "$src")" t="" [ -f "$tgt" ] && t="$(git hash-object --stdin < "$tgt")" # Already up to date. [ "$t" = "$h" ] && continue if [ "$t" = "$b" ]; then # Untouched locally: take the upstream version (or deletion). if [ -z "$h" ]; then rm -f "$tgt" rmdir "$(dirname "$tgt")" 2>/dev/null || true else mkdir -p "$(dirname "$tgt")" git_c cat-file blob "$h" > "$tgt" fi else # Changed on both sides: keep the local copy and record the conflict. printf '%s\n' "$rel" >> "$CONFLICTS" fi done } if store_healthy; then reconcile_upstream fi # --- Step 2: Repair each store's MEMORY.md index ---------------------------- # Drop lines for deleted files; add lines for memories the index misses. # Recently modified files are skipped so their writer can index them. INDEX_GRACE_MIN=10 index_line_target() { printf '%s' "$1" | sed -n 's/^- \[[^]]*\](\([^)]*\.md\)).*/\1/p' } # Drop auto-generated index lines when a hand-written line exists for the same file. drop_superseded_generated_lines() { local buf buf="$(mktemp)" || { cat; return 0; } cat > "$buf" awk ' function target(l, s) { if (!match(l, /^- \[[^]]*\]\([^)]*\.md\)/)) return "" s = substr(l, RSTART, RLENGTH) sub(/^- \[[^]]*\]\(/, "", s); sub(/\)$/, "", s) return s } function generated(l, t, title) { title = t; sub(/\.md$/, "", title); gsub(/-/, " ", title) return index(l, "- [" title "](" t ")") == 1 } NR == FNR { t = target($0) if (t != "" && !generated($0, t)) handwritten[t] = 1 next } { t = target($0) if (t != "" && handwritten[t] && generated($0, t)) next print }' "$buf" "$buf" rm -f "$buf" } sync_memory_index() { local dir="$1" local index="$dir/MEMORY.md" local tmp tmp="$(mktemp)" || return 0 [ -f "$index" ] || printf '# Memory Index\n\n' > "$index" # Drop lines whose file no longer exists. while IFS= read -r line || [ -n "$line" ]; do local target target="$(index_line_target "$line")" if [ -n "$target" ] && [ ! -f "$dir/$target" ]; then continue fi printf '%s\n' "$line" done < "$index" | drop_superseded_generated_lines > "$tmp" # Add a line for every unindexed memory, using its `description:` frontmatter. local f base desc title for f in "$dir"/*.md; do [ -e "$f" ] || continue base="$(basename "$f")" [ "$base" = "MEMORY.md" ] && continue grep -qF "($base)" "$tmp" && continue [ -n "$(find "$f" -mmin -"$INDEX_GRACE_MIN" 2>/dev/null)" ] && continue desc="$(sed -n 's/^description:[[:space:]]*//p' "$f" | head -1 \ | sed 's/^"//; s/"$//; s/\\"/"/g')" title="$(printf '%s' "${base%.md}" | tr '-' ' ')" [ -n "$desc" ] || desc="$title" printf -- '- [%s](%s) — %s\n' "$title" "$base" "$desc" >> "$tmp" done if ! cmp -s "$tmp" "$index"; then cat "$tmp" > "$index" fi rm -f "$tmp" } if store_healthy; then for memdir in "$STORE"/*/; do [ -d "$memdir" ] || continue sync_memory_index "${memdir%/}" done fi # --- Step 3: Mirror the store into chezmoi's source tree -------------------- chezmoi add "$STORE" 2>/dev/null || true # --- Step 4: Propagate deletions and renames -------------------------------- # `chezmoi add` never removes files, so forget entries whose target is gone. # Skipped if the store looks empty, so a broken store can't wipe the backup. if store_healthy; then chezmoi managed "$STORE" 2>/dev/null | sort -r | while IFS= read -r rel; do [ -n "$rel" ] || continue [ -e "$HOME/$rel" ] || chezmoi forget --force "$HOME/$rel" >/dev/null 2>&1 || true done fi # --- Step 5: Guards --------------------------------------------------------- [ -d "$SRC/.git" ] || exit 0 # Don't commit in the middle of a rebase, merge, etc. for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do if [ -e "$SRC/.git/$state" ]; then exit 0 fi done # --- Step 6: Commit (memories path only) ------------------------------------ git_c add -- "$PATHSPEC" 2>/dev/null || exit 0 if ! git_c diff --cached --quiet -- "$PATHSPEC" 2>/dev/null; then git_c commit -q -m "memories: auto-sync $(date +%Y-%m-%dT%H:%M)" -- "$PATHSPEC" 2>/dev/null || exit 0 fi # Record this commit as the base for the next sync. git_c rev-parse HEAD > "$BASEFILE" 2>/dev/null || true # --- Step 7: Push ----------------------------------------------------------- if git_c push -q origin HEAD 2>/dev/null; then rm -f "$SENTINEL" if [ -s "$CONFLICTS" ]; then printf '{"systemMessage":"chezmoi memories: %s memory file(s) changed on another machine AND locally; kept the local copy. Review the list in ~/.claude/.chezmoi-memories-conflicts (git log shows the upstream version), then delete that file."}\n' \ "$(sort -u "$CONFLICTS" | wc -l | tr -d ' ')" fi exit 0 fi # Push failed: the commit is safe locally; report the backlog. n="$(unpushed_count)" [ "$n" = "0" ] && exit 0 report_unpushed "$n" exit 0
#!/usr/bin/env bash # Back the Claude memory store up to chezmoi, then commit and push it. # Runs from the Stop hook. set -uo pipefail SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" PATHSPEC="private_dot_claude/memories" SENTINEL="$HOME/.claude/.chezmoi-memories-unpushed" # Discard the hook's stdin JSON. [ -t 0 ] || cat >/dev/null 2>&1 git_c() { git -C "$SRC" "$@"; } report_unpushed() { local n="$1" printf '%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ) — $n commit(s) unpushed" > "$SENTINEL" printf '{"systemMessage":"chezmoi memories: %s commit(s) committed locally but NOT pushed. Resolve with: git -C ~/.local/share/chezmoi pull --rebase \\u0026\\u0026 git -C ~/.local/share/chezmoi push"}\n' "$n" } unpushed_count() { git_c rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?" } store_healthy() { [ -d "$STORE" ] && [ -n "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Step 1: Pull upstream memory changes into the local store --------------- # Three-way comparison against the commit of the last completed sync. BASEFILE="$HOME/.claude/.chezmoi-memories-base" CONFLICTS="$HOME/.claude/.chezmoi-memories-conflicts" blob_at() { git_c rev-parse -q --verify "$1:$2" 2>/dev/null || true; } # Tree hash the store would have if committed. store_tree() { local idx idx="$(mktemp)" || return 1 rm -f "$idx" GIT_INDEX_FILE="$idx" git_c --work-tree="$STORE" add -- '*.md' 2>/dev/null && GIT_INDEX_FILE="$idx" git_c write-tree rm -f "$idx" } # First run: use the newest commit whose memories tree matches the store. bootstrap_base() { local tree c tree="$(store_tree)" || return 0 git_c log --format=%H -n 200 -- "$PATHSPEC" | while IFS= read -r c; do if [ "$(git_c rev-parse -q --verify "$c:$PATHSPEC")" = "$tree" ]; then printf '%s\n' "$c" break fi done } reconcile_upstream() { local base head src rel tgt b h t base="$(cat "$BASEFILE" 2>/dev/null || bootstrap_base)" head="$(git_c rev-parse -q --verify HEAD)" || return 0 [ -n "$base" ] && [ "$base" != "$head" ] || return 0 git_c cat-file -e "$base^{commit}" 2>/dev/null || return 0 git_c diff --name-only --no-renames "$base" "$head" -- "$PATHSPEC" | while IFS= read -r src; do rel="${src#"$PATHSPEC"/}" tgt="$STORE/$rel" b="$(blob_at "$base" "$src")" h="$(blob_at "$head" "$src")" t="" [ -f "$tgt" ] && t="$(git hash-object --stdin < "$tgt")" # Already up to date. [ "$t" = "$h" ] && continue if [ "$t" = "$b" ]; then # Untouched locally: take the upstream version (or deletion). if [ -z "$h" ]; then rm -f "$tgt" rmdir "$(dirname "$tgt")" 2>/dev/null || true else mkdir -p "$(dirname "$tgt")" git_c cat-file blob "$h" > "$tgt" fi else # Changed on both sides: keep the local copy and record the conflict. printf '%s\n' "$rel" >> "$CONFLICTS" fi done } if store_healthy; then reconcile_upstream fi # --- Step 2: Repair each store's MEMORY.md index ---------------------------- # Drop lines for deleted files; add lines for memories the index misses. # Recently modified files are skipped so their writer can index them. INDEX_GRACE_MIN=10 index_line_target() { printf '%s' "$1" | sed -n 's/^- \[[^]]*\](\([^)]*\.md\)).*/\1/p' } # Drop auto-generated index lines when a hand-written line exists for the same file. drop_superseded_generated_lines() { local buf buf="$(mktemp)" || { cat; return 0; } cat > "$buf" awk ' function target(l, s) { if (!match(l, /^- \[[^]]*\]\([^)]*\.md\)/)) return "" s = substr(l, RSTART, RLENGTH) sub(/^- \[[^]]*\]\(/, "", s); sub(/\)$/, "", s) return s } function generated(l, t, title) { title = t; sub(/\.md$/, "", title); gsub(/-/, " ", title) return index(l, "- [" title "](" t ")") == 1 } NR == FNR { t = target($0) if (t != "" && !generated($0, t)) handwritten[t] = 1 next } { t = target($0) if (t != "" && handwritten[t] && generated($0, t)) next print }' "$buf" "$buf" rm -f "$buf" } sync_memory_index() { local dir="$1" local index="$dir/MEMORY.md" local tmp tmp="$(mktemp)" || return 0 [ -f "$index" ] || printf '# Memory Index\n\n' > "$index" # Drop lines whose file no longer exists. while IFS= read -r line || [ -n "$line" ]; do local target target="$(index_line_target "$line")" if [ -n "$target" ] && [ ! -f "$dir/$target" ]; then continue fi printf '%s\n' "$line" done < "$index" | drop_superseded_generated_lines > "$tmp" # Add a line for every unindexed memory, using its `description:` frontmatter. local f base desc title for f in "$dir"/*.md; do [ -e "$f" ] || continue base="$(basename "$f")" [ "$base" = "MEMORY.md" ] && continue grep -qF "($base)" "$tmp" && continue [ -n "$(find "$f" -mmin -"$INDEX_GRACE_MIN" 2>/dev/null)" ] && continue desc="$(sed -n 's/^description:[[:space:]]*//p' "$f" | head -1 \ | sed 's/^"//; s/"$//; s/\\"/"/g')" title="$(printf '%s' "${base%.md}" | tr '-' ' ')" [ -n "$desc" ] || desc="$title" printf -- '- [%s](%s) — %s\n' "$title" "$base" "$desc" >> "$tmp" done if ! cmp -s "$tmp" "$index"; then cat "$tmp" > "$index" fi rm -f "$tmp" } if store_healthy; then for memdir in "$STORE"/*/; do [ -d "$memdir" ] || continue sync_memory_index "${memdir%/}" done fi # --- Step 3: Mirror the store into chezmoi's source tree -------------------- chezmoi add "$STORE" 2>/dev/null || true # --- Step 4: Propagate deletions and renames -------------------------------- # `chezmoi add` never removes files, so forget entries whose target is gone. # Skipped if the store looks empty, so a broken store can't wipe the backup. if store_healthy; then chezmoi managed "$STORE" 2>/dev/null | sort -r | while IFS= read -r rel; do [ -n "$rel" ] || continue [ -e "$HOME/$rel" ] || chezmoi forget --force "$HOME/$rel" >/dev/null 2>&1 || true done fi # --- Step 5: Guards --------------------------------------------------------- [ -d "$SRC/.git" ] || exit 0 # Don't commit in the middle of a rebase, merge, etc. for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do if [ -e "$SRC/.git/$state" ]; then exit 0 fi done # --- Step 6: Commit (memories path only) ------------------------------------ git_c add -- "$PATHSPEC" 2>/dev/null || exit 0 if ! git_c diff --cached --quiet -- "$PATHSPEC" 2>/dev/null; then git_c commit -q -m "memories: auto-sync $(date +%Y-%m-%dT%H:%M)" -- "$PATHSPEC" 2>/dev/null || exit 0 fi # Record this commit as the base for the next sync. git_c rev-parse HEAD > "$BASEFILE" 2>/dev/null || true # --- Step 7: Push ----------------------------------------------------------- if git_c push -q origin HEAD 2>/dev/null; then rm -f "$SENTINEL" if [ -s "$CONFLICTS" ]; then printf '{"systemMessage":"chezmoi memories: %s memory file(s) changed on another machine AND locally; kept the local copy. Review the list in ~/.claude/.chezmoi-memories-conflicts (git log shows the upstream version), then delete that file."}\n' \ "$(sort -u "$CONFLICTS" | wc -l | tr -d ' ')" fi exit 0 fi # Push failed: the commit is safe locally; report the backlog. n="$(unpushed_count)" [ "$n" = "0" ] && exit 0 report_unpushed "$n" exit 0
Two hooks manage the sync lifecycle for memories:
The first hook is registered to the ‘PostToolUse’ event on ‘Write|Edit’: It does immediate per-file backup. It fires after every file write or edit and it does the following:
gets the path of the file from the tool call with ‘jq’ and resolves it to its absolute path on the machine
If the file is under ~/.claude/memories, it runs the ‘chezmoi add’ command on the file.
It ends in ‘|| true’, so any crash or failure on the hook doesn’t interfere with the claude session.
The effect is that every new or updated memory automatically lands in the chezmoi/dotfiles source right away. It means it’s already there for when the second hook runs, and it also covers the case where a memory is written but the claude session subsequently crashes for an unrelated reason.
The second hook is registered on Claude’s stop event and does most of the heavy lifting. It fires after every time claude stops responding and runs a script called ‘chezmoi-memories-sync.sh’, which does the following things:
Reconciles the upstream: It does a 3-way comparison between the store, HEAD, and a saved “last synced” commit (~/.claude/.chezmoi-memories-base). Changes made on the other machine are applied to the local store, including deletions. If both sides changed the file, the local copy wins and the path goes to a conflicts file. Without this step, the next step would overwrite the other machine’s changes with stale local files.
Repair MEMORY.md: This file is claude’s ‘index’ of all its memories, although it can find memories anywhere, this is where it actually looks when it’s working and wants to check if it has memories on a given subject. This step treats the index as derived state. It drops lines pointing to deleted files and appends a line for any memory missing from the index, using the file’s
description:frontmatter. Hand-written lines are never rewritten and files that were recently updated are skipped so that you don’t have conflicts on a memory that the agent is currently writing.Mirror: This is the step that actually syncs the changes to the dotfiles repo. It does it like this:
Sweep: Runs ‘chezmoi add ~/.claude/memories’ which is a catch all to grab any changes that didn’t fall under the first hook, and add them to the source tree.
Propagate deletions: ‘chezmoi add’ never removes anything, so this runs ‘chezmoi forget’ on managed entries whose target is gone. Otherwise a deleted memory would just come back on the next chezmoi sync.
Guards: This step exits quietly if the source tree isn’t a git repo, or a rebase/merge/cherry-pick is currently in progress. This keeps any potentially catastrophic merge/rebase situations from being decided or overwritten by the script, leaving it up to the user to resolve manually.
Commit: Stages and commits only
private_dot_claude/memories, so any other dotfile changes you have in progress in the chezmoi repo never get swept into an auto-sync commit.Push: Pushes to origin. On success it clears the “unpushed” sentinel and reports any conflicts/issues it ran into through a systemMessage. On failure, the commit is still in the repo locally, it’s just not pushed to the remote. It writes a sentinel file and shows a systemMessage with the suggested fix.
Together these two hooks manage the lifecycle for memory sync itself. They never leave the repo stuck in a mid-rebase state or add chezmoi-managed files that aren’t explicitly part of claude’s memory system, and they never crash the current claude session on failures.
One thing the hooks don’t do is pull. The reconcile step compares against the local chezmoi repo, so changes from another machine (or a cloud session) only show up once that repo has them. Running chezmoi update pulls and applies them, and the hooks take it from there. You could add chezmoi update to your ~/.zshrc (or your shell of choice’s startup file) so it happens automatically, but for now I prefer to run it manually when I sit down at a machine.
Here are the hooks. They go under the "hooks" key in ~/.claude/settings.json, and the Stop hook expects the script below saved as ~/.claude/hooks/chezmoi-memories-sync.sh and made executable:
{ "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "jq -r '.tool_response.filePath // .tool_input.file_path // empty' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true", "timeout": 20, "statusMessage": "Backing up memory to chezmoi" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/chezmoi-memories-sync.sh", "timeout": 60, "async": true, "statusMessage": "Syncing memories to chezmoi" } ] } ] }
Here’s the script:
#!/usr/bin/env bash # Back the Claude memory store up to chezmoi, then commit and push it. # Runs from the Stop hook. set -uo pipefail SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" PATHSPEC="private_dot_claude/memories" SENTINEL="$HOME/.claude/.chezmoi-memories-unpushed" # Discard the hook's stdin JSON. [ -t 0 ] || cat >/dev/null 2>&1 git_c() { git -C "$SRC" "$@"; } report_unpushed() { local n="$1" printf '%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ) — $n commit(s) unpushed" > "$SENTINEL" printf '{"systemMessage":"chezmoi memories: %s commit(s) committed locally but NOT pushed. Resolve with: git -C ~/.local/share/chezmoi pull --rebase \\u0026\\u0026 git -C ~/.local/share/chezmoi push"}\n' "$n" } unpushed_count() { git_c rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?" } store_healthy() { [ -d "$STORE" ] && [ -n "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Step 1: Pull upstream memory changes into the local store --------------- # Three-way comparison against the commit of the last completed sync. BASEFILE="$HOME/.claude/.chezmoi-memories-base" CONFLICTS="$HOME/.claude/.chezmoi-memories-conflicts" blob_at() { git_c rev-parse -q --verify "$1:$2" 2>/dev/null || true; } # Tree hash the store would have if committed. store_tree() { local idx idx="$(mktemp)" || return 1 rm -f "$idx" GIT_INDEX_FILE="$idx" git_c --work-tree="$STORE" add -- '*.md' 2>/dev/null && GIT_INDEX_FILE="$idx" git_c write-tree rm -f "$idx" } # First run: use the newest commit whose memories tree matches the store. bootstrap_base() { local tree c tree="$(store_tree)" || return 0 git_c log --format=%H -n 200 -- "$PATHSPEC" | while IFS= read -r c; do if [ "$(git_c rev-parse -q --verify "$c:$PATHSPEC")" = "$tree" ]; then printf '%s\n' "$c" break fi done } reconcile_upstream() { local base head src rel tgt b h t base="$(cat "$BASEFILE" 2>/dev/null || bootstrap_base)" head="$(git_c rev-parse -q --verify HEAD)" || return 0 [ -n "$base" ] && [ "$base" != "$head" ] || return 0 git_c cat-file -e "$base^{commit}" 2>/dev/null || return 0 git_c diff --name-only --no-renames "$base" "$head" -- "$PATHSPEC" | while IFS= read -r src; do rel="${src#"$PATHSPEC"/}" tgt="$STORE/$rel" b="$(blob_at "$base" "$src")" h="$(blob_at "$head" "$src")" t="" [ -f "$tgt" ] && t="$(git hash-object --stdin < "$tgt")" # Already up to date. [ "$t" = "$h" ] && continue if [ "$t" = "$b" ]; then # Untouched locally: take the upstream version (or deletion). if [ -z "$h" ]; then rm -f "$tgt" rmdir "$(dirname "$tgt")" 2>/dev/null || true else mkdir -p "$(dirname "$tgt")" git_c cat-file blob "$h" > "$tgt" fi else # Changed on both sides: keep the local copy and record the conflict. printf '%s\n' "$rel" >> "$CONFLICTS" fi done } if store_healthy; then reconcile_upstream fi # --- Step 2: Repair each store's MEMORY.md index ---------------------------- # Drop lines for deleted files; add lines for memories the index misses. # Recently modified files are skipped so their writer can index them. INDEX_GRACE_MIN=10 index_line_target() { printf '%s' "$1" | sed -n 's/^- \[[^]]*\](\([^)]*\.md\)).*/\1/p' } # Drop auto-generated index lines when a hand-written line exists for the same file. drop_superseded_generated_lines() { local buf buf="$(mktemp)" || { cat; return 0; } cat > "$buf" awk ' function target(l, s) { if (!match(l, /^- \[[^]]*\]\([^)]*\.md\)/)) return "" s = substr(l, RSTART, RLENGTH) sub(/^- \[[^]]*\]\(/, "", s); sub(/\)$/, "", s) return s } function generated(l, t, title) { title = t; sub(/\.md$/, "", title); gsub(/-/, " ", title) return index(l, "- [" title "](" t ")") == 1 } NR == FNR { t = target($0) if (t != "" && !generated($0, t)) handwritten[t] = 1 next } { t = target($0) if (t != "" && handwritten[t] && generated($0, t)) next print }' "$buf" "$buf" rm -f "$buf" } sync_memory_index() { local dir="$1" local index="$dir/MEMORY.md" local tmp tmp="$(mktemp)" || return 0 [ -f "$index" ] || printf '# Memory Index\n\n' > "$index" # Drop lines whose file no longer exists. while IFS= read -r line || [ -n "$line" ]; do local target target="$(index_line_target "$line")" if [ -n "$target" ] && [ ! -f "$dir/$target" ]; then continue fi printf '%s\n' "$line" done < "$index" | drop_superseded_generated_lines > "$tmp" # Add a line for every unindexed memory, using its `description:` frontmatter. local f base desc title for f in "$dir"/*.md; do [ -e "$f" ] || continue base="$(basename "$f")" [ "$base" = "MEMORY.md" ] && continue grep -qF "($base)" "$tmp" && continue [ -n "$(find "$f" -mmin -"$INDEX_GRACE_MIN" 2>/dev/null)" ] && continue desc="$(sed -n 's/^description:[[:space:]]*//p' "$f" | head -1 \ | sed 's/^"//; s/"$//; s/\\"/"/g')" title="$(printf '%s' "${base%.md}" | tr '-' ' ')" [ -n "$desc" ] || desc="$title" printf -- '- [%s](%s) — %s\n' "$title" "$base" "$desc" >> "$tmp" done if ! cmp -s "$tmp" "$index"; then cat "$tmp" > "$index" fi rm -f "$tmp" } if store_healthy; then for memdir in "$STORE"/*/; do [ -d "$memdir" ] || continue sync_memory_index "${memdir%/}" done fi # --- Step 3: Mirror the store into chezmoi's source tree -------------------- chezmoi add "$STORE" 2>/dev/null || true # --- Step 4: Propagate deletions and renames -------------------------------- # `chezmoi add` never removes files, so forget entries whose target is gone. # Skipped if the store looks empty, so a broken store can't wipe the backup. if store_healthy; then chezmoi managed "$STORE" 2>/dev/null | sort -r | while IFS= read -r rel; do [ -n "$rel" ] || continue [ -e "$HOME/$rel" ] || chezmoi forget --force "$HOME/$rel" >/dev/null 2>&1 || true done fi # --- Step 5: Guards --------------------------------------------------------- [ -d "$SRC/.git" ] || exit 0 # Don't commit in the middle of a rebase, merge, etc. for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do if [ -e "$SRC/.git/$state" ]; then exit 0 fi done # --- Step 6: Commit (memories path only) ------------------------------------ git_c add -- "$PATHSPEC" 2>/dev/null || exit 0 if ! git_c diff --cached --quiet -- "$PATHSPEC" 2>/dev/null; then git_c commit -q -m "memories: auto-sync $(date +%Y-%m-%dT%H:%M)" -- "$PATHSPEC" 2>/dev/null || exit 0 fi # Record this commit as the base for the next sync. git_c rev-parse HEAD > "$BASEFILE" 2>/dev/null || true # --- Step 7: Push ----------------------------------------------------------- if git_c push -q origin HEAD 2>/dev/null; then rm -f "$SENTINEL" if [ -s "$CONFLICTS" ]; then printf '{"systemMessage":"chezmoi memories: %s memory file(s) changed on another machine AND locally; kept the local copy. Review the list in ~/.claude/.chezmoi-memories-conflicts (git log shows the upstream version), then delete that file."}\n' \ "$(sort -u "$CONFLICTS" | wc -l | tr -d ' ')" fi exit 0 fi # Push failed: the commit is safe locally; report the backlog. n="$(unpushed_count)" [ "$n" = "0" ] && exit 0 report_unpushed "$n" exit 0
There are probably multiple ways to do this, but I found the easiest is just to add your dotfiles repo as an extra repository in the session. All you do is click the ‘+’ button over the chat input in the desktop app, find your dotfiles repo in the dropdown, and add it so it gets cloned into the new session.
Setting up the environment
Next we need to set up the environment. You’ll need to add a new environment because your dotfiles are your personal config/memories, and they won’t apply to anybody else’s setup. Above the chat input in the desktop app, to the left of the repos, there’s an environment selector. If you go Cloud>Add cloud environment… you can add a new one. Hovering over the new environment will pop a little gear icon to open the settings. In that pane, you can paste in the setup script I provided below. You’ll also need to put CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 in the env vars for the new environment.
What the script does
Seeds the memory store before claude starts. There’s no GitHub auth while the setup script runs, so it can’t clone dotfiles yet. But the attached dotfiles repo is already checked out at
/home/user/dotfiles, so the script points chezmoi straight at it (chezmoi apply --source /home/user/dotfiles ~/.claude/memories). That way the memories are there from the very first prompt.Links each project’s memory dir into the store, same as on my machines. With dotfiles attached, claude runs from
/home/userinstead of the repo, so the script links that parent dir too.Registers the hooks in a
~/.claude/settings.jsoninside the VM: the same PostToolUse and Stop hooks as on my machines, plus a SessionStart hook that does the realgit cloneonce auth works. From then onchezmoi-memories-sync.shtakes over, so memories claude writes in the cloud get committed and pushed back to dotfiles just like on my laptop. The pull lives in the hook rather than the setup script because the environment caches the setup script’s result for up to a week.
Everything is wrapped so the script always exits 0 (a failing setup script means no session at all), and it logs to ~/.claude/cloud-memories.log. The one thing it can’t do itself is turn auto memory on: cloud sessions disable it, and autoMemoryEnabled in settings doesn’t override that, which is why the environment needs the CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 env var.
One gotcha: in my test, the cloud session’s auto-mode classifier blocked claude from editing MEMORY.md, flagging it as “Instruction Poisoning” (fair enough, it’s a file that gets injected into every future session). The memory file itself still synced, and the sync script’s index repair step adds the missing line on its own about 10 minutes later, which is exactly the situation that step was built for.
Here’s the setup script I paste into the cloud environment:
#!/bin/bash # Claude Code cloud environment setup script. # Restores the memory store from dotfiles and registers the sync hooks. # Must always exit 0, or the session fails to start. set -u HOOKS="$HOME/.claude/hooks" setup() { # --- Step 1: Tools --------------------------------------------------------- # get.chezmoi.io isn't on the Trusted allowlist, so install from GitHub. command -v chezmoi >/dev/null || sh -c "$(curl -fsSL https://raw.githubusercontent.com/twpayne/chezmoi/master/assets/scripts/install.sh)" -- -b /usr/local/bin command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; } # --- Step 2: Pull-and-link script ------------------------------------------ mkdir -p "$HOOKS" cat > "$HOOKS/cloud-memories-pull.sh" <<'EOF' #!/bin/bash # Pull memories from dotfiles and link project memory dirs into the store. # Usage: as a SessionStart hook (cwd from stdin JSON), or with dirs as args. set -u # Your dotfiles repo on GitHub, as <owner>/<repo>. DOTFILES_SLUG="<github-user>/<dotfiles-repo>" DOTFILES_REPO="https://github.com/$DOTFILES_SLUG.git" SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" BASEFILE="$HOME/.claude/.chezmoi-memories-base" LOG="$HOME/.claude/cloud-memories.log" exec 2>>"$LOG" echo "--- $(date -u +%FT%TZ) user=$(whoami) pwd=$PWD args=$*" >&2 dirs=("$@") if [ ${#dirs[@]} -eq 0 ] && [ ! -t 0 ]; then cwd="$(jq -r '.cwd // empty' 2>/dev/null)" [ -n "$cwd" ] && dirs=("$cwd") fi # The dotfiles checkout attached to the session, if any. attached_checkout() { local d for d in /home/user/*/; do case "$(git -C "$d" remote get-url origin 2>/dev/null)" in *"$DOTFILES_SLUG"*) printf '%s' "${d%/}"; return 0 ;; esac done return 1 } store_empty() { [ -z "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Get the dotfiles repo ---------------------------------------------------- # GitHub auth only exists once the session runs, so this fails at setup time. if ! git -C "$SRC" rev-parse -q --verify HEAD >/dev/null 2>&1; then rm -rf "$SRC" if git clone -q "$DOTFILES_REPO" "$SRC"; then git -C "$SRC" config user.name "<your-git-name>" git -C "$SRC" config user.email "<your-git-email>" else rm -rf "$SRC" fi fi if [ -d "$SRC/.git" ]; then # Fast-forward only; a diverged history is left for the Stop sync to report. git -C "$SRC" pull -q --ff-only || true if store_empty; then # Fresh VM: restore the store and mark this commit as the sync base. chezmoi apply --force "$STORE" || true git -C "$SRC" rev-parse HEAD > "$BASEFILE" else # Existing store: three-way reconcile with upstream, then commit and push. bash "$SRC/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" </dev/null fi elif store_empty && checkout="$(attached_checkout)"; then # No clone yet: seed the store from the session's own dotfiles checkout. chezmoi apply --source "$checkout" --force "$STORE" || true fi # --- Link project memory dirs --------------------------------------------------- store_for_repo() { local repo repo="$(basename "$(git -C "$1" remote get-url origin 2>/dev/null)" .git)" [ -n "$repo" ] || repo="$(basename "$1")" case "$repo" in # Map any repo whose store name differs from the repo name, e.g.: # <repo-name>) printf '<store-name>' ;; *) printf '%s' "$repo" ;; esac } # A repo uses its own store; a parent dir (multi-repo session) uses its single # work repo's store, else dev-root. link_dir() { local dir="$1" top store repos project_dir top="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" if [ -n "$top" ]; then store="$(store_for_repo "$top")" else top="$dir" repos="$(find "$dir" -mindepth 2 -maxdepth 2 -name .git -printf '%h\n' 2>/dev/null | grep -v '/dotfiles$')" if [ "$(printf '%s' "$repos" | grep -c .)" = "1" ]; then store="$(store_for_repo "$repos")" else store="dev-root" fi fi [ "$store" = "dotfiles" ] && return 0 echo "link $top -> $store" >&2 # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$top" | tr '/.' '-')" mkdir -p "$STORE/$store" "$project_dir" if [ -d "$project_dir/memory" ] && [ ! -L "$project_dir/memory" ]; then cp -Rn "$project_dir/memory"/. "$STORE/$store"/ 2>/dev/null || true rm -rf "$project_dir/memory" fi ln -sfn "$STORE/$store" "$project_dir/memory" } for d in "${dirs[@]}"; do link_dir "$d" done exit 0 EOF chmod +x "$HOOKS/cloud-memories-pull.sh" # --- Step 3: Settings: enable auto memory, register hooks ------------------ local sync="bash \$HOME/.local/share/chezmoi/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" local cfg cfg="$(jq -n --arg pull "$HOOKS/cloud-memories-pull.sh" --arg sync "$sync" '{ autoMemoryEnabled: true, hooks: { SessionStart: [{hooks: [{type: "command", command: $pull, timeout: 60}]}], PostToolUse: [{matcher: "Write|Edit", hooks: [{type: "command", timeout: 20, command: "jq -r '\''.tool_response.filePath // .tool_input.file_path // empty'\'' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true"}]}], Stop: [{hooks: [{type: "command", command: $sync, timeout: 60, async: true}]}] }}')" || return 1 local settings="$HOME/.claude/settings.json" if [ -s "$settings" ]; then jq --argjson c "$cfg" '. * $c' "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings" else printf '%s\n' "$cfg" > "$settings" fi # --- Step 4: Restore and link now, before Claude starts -------------------- # Link the repos dir (multi-repo sessions) and each repo (single-repo sessions). local targets=(/home/user) local d for d in /home/user/*/; do [ -d "$d/.git" ] && targets+=("${d%/}") done "$HOOKS/cloud-memories-pull.sh" "${targets[@]}" </dev/null } setup || echo "cloud memory setup failed; continuing without synced memories" >&2 exit 0
#!/bin/bash # Claude Code cloud environment setup script. # Restores the memory store from dotfiles and registers the sync hooks. # Must always exit 0, or the session fails to start. set -u HOOKS="$HOME/.claude/hooks" setup() { # --- Step 1: Tools --------------------------------------------------------- # get.chezmoi.io isn't on the Trusted allowlist, so install from GitHub. command -v chezmoi >/dev/null || sh -c "$(curl -fsSL https://raw.githubusercontent.com/twpayne/chezmoi/master/assets/scripts/install.sh)" -- -b /usr/local/bin command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; } # --- Step 2: Pull-and-link script ------------------------------------------ mkdir -p "$HOOKS" cat > "$HOOKS/cloud-memories-pull.sh" <<'EOF' #!/bin/bash # Pull memories from dotfiles and link project memory dirs into the store. # Usage: as a SessionStart hook (cwd from stdin JSON), or with dirs as args. set -u # Your dotfiles repo on GitHub, as <owner>/<repo>. DOTFILES_SLUG="<github-user>/<dotfiles-repo>" DOTFILES_REPO="https://github.com/$DOTFILES_SLUG.git" SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" BASEFILE="$HOME/.claude/.chezmoi-memories-base" LOG="$HOME/.claude/cloud-memories.log" exec 2>>"$LOG" echo "--- $(date -u +%FT%TZ) user=$(whoami) pwd=$PWD args=$*" >&2 dirs=("$@") if [ ${#dirs[@]} -eq 0 ] && [ ! -t 0 ]; then cwd="$(jq -r '.cwd // empty' 2>/dev/null)" [ -n "$cwd" ] && dirs=("$cwd") fi # The dotfiles checkout attached to the session, if any. attached_checkout() { local d for d in /home/user/*/; do case "$(git -C "$d" remote get-url origin 2>/dev/null)" in *"$DOTFILES_SLUG"*) printf '%s' "${d%/}"; return 0 ;; esac done return 1 } store_empty() { [ -z "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Get the dotfiles repo ---------------------------------------------------- # GitHub auth only exists once the session runs, so this fails at setup time. if ! git -C "$SRC" rev-parse -q --verify HEAD >/dev/null 2>&1; then rm -rf "$SRC" if git clone -q "$DOTFILES_REPO" "$SRC"; then git -C "$SRC" config user.name "<your-git-name>" git -C "$SRC" config user.email "<your-git-email>" else rm -rf "$SRC" fi fi if [ -d "$SRC/.git" ]; then # Fast-forward only; a diverged history is left for the Stop sync to report. git -C "$SRC" pull -q --ff-only || true if store_empty; then # Fresh VM: restore the store and mark this commit as the sync base. chezmoi apply --force "$STORE" || true git -C "$SRC" rev-parse HEAD > "$BASEFILE" else # Existing store: three-way reconcile with upstream, then commit and push. bash "$SRC/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" </dev/null fi elif store_empty && checkout="$(attached_checkout)"; then # No clone yet: seed the store from the session's own dotfiles checkout. chezmoi apply --source "$checkout" --force "$STORE" || true fi # --- Link project memory dirs --------------------------------------------------- store_for_repo() { local repo repo="$(basename "$(git -C "$1" remote get-url origin 2>/dev/null)" .git)" [ -n "$repo" ] || repo="$(basename "$1")" case "$repo" in # Map any repo whose store name differs from the repo name, e.g.: # <repo-name>) printf '<store-name>' ;; *) printf '%s' "$repo" ;; esac } # A repo uses its own store; a parent dir (multi-repo session) uses its single # work repo's store, else dev-root. link_dir() { local dir="$1" top store repos project_dir top="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" if [ -n "$top" ]; then store="$(store_for_repo "$top")" else top="$dir" repos="$(find "$dir" -mindepth 2 -maxdepth 2 -name .git -printf '%h\n' 2>/dev/null | grep -v '/dotfiles$')" if [ "$(printf '%s' "$repos" | grep -c .)" = "1" ]; then store="$(store_for_repo "$repos")" else store="dev-root" fi fi [ "$store" = "dotfiles" ] && return 0 echo "link $top -> $store" >&2 # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$top" | tr '/.' '-')" mkdir -p "$STORE/$store" "$project_dir" if [ -d "$project_dir/memory" ] && [ ! -L "$project_dir/memory" ]; then cp -Rn "$project_dir/memory"/. "$STORE/$store"/ 2>/dev/null || true rm -rf "$project_dir/memory" fi ln -sfn "$STORE/$store" "$project_dir/memory" } for d in "${dirs[@]}"; do link_dir "$d" done exit 0 EOF chmod +x "$HOOKS/cloud-memories-pull.sh" # --- Step 3: Settings: enable auto memory, register hooks ------------------ local sync="bash \$HOME/.local/share/chezmoi/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" local cfg cfg="$(jq -n --arg pull "$HOOKS/cloud-memories-pull.sh" --arg sync "$sync" '{ autoMemoryEnabled: true, hooks: { SessionStart: [{hooks: [{type: "command", command: $pull, timeout: 60}]}], PostToolUse: [{matcher: "Write|Edit", hooks: [{type: "command", timeout: 20, command: "jq -r '\''.tool_response.filePath // .tool_input.file_path // empty'\'' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true"}]}], Stop: [{hooks: [{type: "command", command: $sync, timeout: 60, async: true}]}] }}')" || return 1 local settings="$HOME/.claude/settings.json" if [ -s "$settings" ]; then jq --argjson c "$cfg" '. * $c' "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings" else printf '%s\n' "$cfg" > "$settings" fi # --- Step 4: Restore and link now, before Claude starts -------------------- # Link the repos dir (multi-repo sessions) and each repo (single-repo sessions). local targets=(/home/user) local d for d in /home/user/*/; do [ -d "$d/.git" ] && targets+=("${d%/}") done "$HOOKS/cloud-memories-pull.sh" "${targets[@]}" </dev/null } setup || echo "cloud memory setup failed; continuing without synced memories" >&2 exit 0
#!/bin/bash # Claude Code cloud environment setup script. # Restores the memory store from dotfiles and registers the sync hooks. # Must always exit 0, or the session fails to start. set -u HOOKS="$HOME/.claude/hooks" setup() { # --- Step 1: Tools --------------------------------------------------------- # get.chezmoi.io isn't on the Trusted allowlist, so install from GitHub. command -v chezmoi >/dev/null || sh -c "$(curl -fsSL https://raw.githubusercontent.com/twpayne/chezmoi/master/assets/scripts/install.sh)" -- -b /usr/local/bin command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; } # --- Step 2: Pull-and-link script ------------------------------------------ mkdir -p "$HOOKS" cat > "$HOOKS/cloud-memories-pull.sh" <<'EOF' #!/bin/bash # Pull memories from dotfiles and link project memory dirs into the store. # Usage: as a SessionStart hook (cwd from stdin JSON), or with dirs as args. set -u # Your dotfiles repo on GitHub, as <owner>/<repo>. DOTFILES_SLUG="<github-user>/<dotfiles-repo>" DOTFILES_REPO="https://github.com/$DOTFILES_SLUG.git" SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" BASEFILE="$HOME/.claude/.chezmoi-memories-base" LOG="$HOME/.claude/cloud-memories.log" exec 2>>"$LOG" echo "--- $(date -u +%FT%TZ) user=$(whoami) pwd=$PWD args=$*" >&2 dirs=("$@") if [ ${#dirs[@]} -eq 0 ] && [ ! -t 0 ]; then cwd="$(jq -r '.cwd // empty' 2>/dev/null)" [ -n "$cwd" ] && dirs=("$cwd") fi # The dotfiles checkout attached to the session, if any. attached_checkout() { local d for d in /home/user/*/; do case "$(git -C "$d" remote get-url origin 2>/dev/null)" in *"$DOTFILES_SLUG"*) printf '%s' "${d%/}"; return 0 ;; esac done return 1 } store_empty() { [ -z "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Get the dotfiles repo ---------------------------------------------------- # GitHub auth only exists once the session runs, so this fails at setup time. if ! git -C "$SRC" rev-parse -q --verify HEAD >/dev/null 2>&1; then rm -rf "$SRC" if git clone -q "$DOTFILES_REPO" "$SRC"; then git -C "$SRC" config user.name "<your-git-name>" git -C "$SRC" config user.email "<your-git-email>" else rm -rf "$SRC" fi fi if [ -d "$SRC/.git" ]; then # Fast-forward only; a diverged history is left for the Stop sync to report. git -C "$SRC" pull -q --ff-only || true if store_empty; then # Fresh VM: restore the store and mark this commit as the sync base. chezmoi apply --force "$STORE" || true git -C "$SRC" rev-parse HEAD > "$BASEFILE" else # Existing store: three-way reconcile with upstream, then commit and push. bash "$SRC/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" </dev/null fi elif store_empty && checkout="$(attached_checkout)"; then # No clone yet: seed the store from the session's own dotfiles checkout. chezmoi apply --source "$checkout" --force "$STORE" || true fi # --- Link project memory dirs --------------------------------------------------- store_for_repo() { local repo repo="$(basename "$(git -C "$1" remote get-url origin 2>/dev/null)" .git)" [ -n "$repo" ] || repo="$(basename "$1")" case "$repo" in # Map any repo whose store name differs from the repo name, e.g.: # <repo-name>) printf '<store-name>' ;; *) printf '%s' "$repo" ;; esac } # A repo uses its own store; a parent dir (multi-repo session) uses its single # work repo's store, else dev-root. link_dir() { local dir="$1" top store repos project_dir top="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" if [ -n "$top" ]; then store="$(store_for_repo "$top")" else top="$dir" repos="$(find "$dir" -mindepth 2 -maxdepth 2 -name .git -printf '%h\n' 2>/dev/null | grep -v '/dotfiles$')" if [ "$(printf '%s' "$repos" | grep -c .)" = "1" ]; then store="$(store_for_repo "$repos")" else store="dev-root" fi fi [ "$store" = "dotfiles" ] && return 0 echo "link $top -> $store" >&2 # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$top" | tr '/.' '-')" mkdir -p "$STORE/$store" "$project_dir" if [ -d "$project_dir/memory" ] && [ ! -L "$project_dir/memory" ]; then cp -Rn "$project_dir/memory"/. "$STORE/$store"/ 2>/dev/null || true rm -rf "$project_dir/memory" fi ln -sfn "$STORE/$store" "$project_dir/memory" } for d in "${dirs[@]}"; do link_dir "$d" done exit 0 EOF chmod +x "$HOOKS/cloud-memories-pull.sh" # --- Step 3: Settings: enable auto memory, register hooks ------------------ local sync="bash \$HOME/.local/share/chezmoi/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" local cfg cfg="$(jq -n --arg pull "$HOOKS/cloud-memories-pull.sh" --arg sync "$sync" '{ autoMemoryEnabled: true, hooks: { SessionStart: [{hooks: [{type: "command", command: $pull, timeout: 60}]}], PostToolUse: [{matcher: "Write|Edit", hooks: [{type: "command", timeout: 20, command: "jq -r '\''.tool_response.filePath // .tool_input.file_path // empty'\'' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true"}]}], Stop: [{hooks: [{type: "command", command: $sync, timeout: 60, async: true}]}] }}')" || return 1 local settings="$HOME/.claude/settings.json" if [ -s "$settings" ]; then jq --argjson c "$cfg" '. * $c' "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings" else printf '%s\n' "$cfg" > "$settings" fi # --- Step 4: Restore and link now, before Claude starts -------------------- # Link the repos dir (multi-repo sessions) and each repo (single-repo sessions). local targets=(/home/user) local d for d in /home/user/*/; do [ -d "$d/.git" ] && targets+=("${d%/}") done "$HOOKS/cloud-memories-pull.sh" "${targets[@]}" </dev/null } setup || echo "cloud memory setup failed; continuing without synced memories" >&2 exit 0
There are probably multiple ways to do this, but I found the easiest is just to add your dotfiles repo as an extra repository in the session. All you do is click the ‘+’ button over the chat input in the desktop app, find your dotfiles repo in the dropdown, and add it so it gets cloned into the new session.
Setting up the environment
Next we need to set up the environment. You’ll need to add a new environment because your dotfiles are your personal config/memories, and they won’t apply to anybody else’s setup. Above the chat input in the desktop app, to the left of the repos, there’s an environment selector. If you go Cloud>Add cloud environment… you can add a new one. Hovering over the new environment will pop a little gear icon to open the settings. In that pane, you can paste in the setup script I provided below. You’ll also need to put CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 in the env vars for the new environment.
What the script does
Seeds the memory store before claude starts. There’s no GitHub auth while the setup script runs, so it can’t clone dotfiles yet. But the attached dotfiles repo is already checked out at
/home/user/dotfiles, so the script points chezmoi straight at it (chezmoi apply --source /home/user/dotfiles ~/.claude/memories). That way the memories are there from the very first prompt.Links each project’s memory dir into the store, same as on my machines. With dotfiles attached, claude runs from
/home/userinstead of the repo, so the script links that parent dir too.Registers the hooks in a
~/.claude/settings.jsoninside the VM: the same PostToolUse and Stop hooks as on my machines, plus a SessionStart hook that does the realgit cloneonce auth works. From then onchezmoi-memories-sync.shtakes over, so memories claude writes in the cloud get committed and pushed back to dotfiles just like on my laptop. The pull lives in the hook rather than the setup script because the environment caches the setup script’s result for up to a week.
Everything is wrapped so the script always exits 0 (a failing setup script means no session at all), and it logs to ~/.claude/cloud-memories.log. The one thing it can’t do itself is turn auto memory on: cloud sessions disable it, and autoMemoryEnabled in settings doesn’t override that, which is why the environment needs the CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 env var.
One gotcha: in my test, the cloud session’s auto-mode classifier blocked claude from editing MEMORY.md, flagging it as “Instruction Poisoning” (fair enough, it’s a file that gets injected into every future session). The memory file itself still synced, and the sync script’s index repair step adds the missing line on its own about 10 minutes later, which is exactly the situation that step was built for.
Here’s the setup script I paste into the cloud environment:
#!/bin/bash # Claude Code cloud environment setup script. # Restores the memory store from dotfiles and registers the sync hooks. # Must always exit 0, or the session fails to start. set -u HOOKS="$HOME/.claude/hooks" setup() { # --- Step 1: Tools --------------------------------------------------------- # get.chezmoi.io isn't on the Trusted allowlist, so install from GitHub. command -v chezmoi >/dev/null || sh -c "$(curl -fsSL https://raw.githubusercontent.com/twpayne/chezmoi/master/assets/scripts/install.sh)" -- -b /usr/local/bin command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; } # --- Step 2: Pull-and-link script ------------------------------------------ mkdir -p "$HOOKS" cat > "$HOOKS/cloud-memories-pull.sh" <<'EOF' #!/bin/bash # Pull memories from dotfiles and link project memory dirs into the store. # Usage: as a SessionStart hook (cwd from stdin JSON), or with dirs as args. set -u # Your dotfiles repo on GitHub, as <owner>/<repo>. DOTFILES_SLUG="<github-user>/<dotfiles-repo>" DOTFILES_REPO="https://github.com/$DOTFILES_SLUG.git" SRC="$HOME/.local/share/chezmoi" STORE="$HOME/.claude/memories" BASEFILE="$HOME/.claude/.chezmoi-memories-base" LOG="$HOME/.claude/cloud-memories.log" exec 2>>"$LOG" echo "--- $(date -u +%FT%TZ) user=$(whoami) pwd=$PWD args=$*" >&2 dirs=("$@") if [ ${#dirs[@]} -eq 0 ] && [ ! -t 0 ]; then cwd="$(jq -r '.cwd // empty' 2>/dev/null)" [ -n "$cwd" ] && dirs=("$cwd") fi # The dotfiles checkout attached to the session, if any. attached_checkout() { local d for d in /home/user/*/; do case "$(git -C "$d" remote get-url origin 2>/dev/null)" in *"$DOTFILES_SLUG"*) printf '%s' "${d%/}"; return 0 ;; esac done return 1 } store_empty() { [ -z "$(find "$STORE" -type f -name '*.md' -print -quit 2>/dev/null)" ] } # --- Get the dotfiles repo ---------------------------------------------------- # GitHub auth only exists once the session runs, so this fails at setup time. if ! git -C "$SRC" rev-parse -q --verify HEAD >/dev/null 2>&1; then rm -rf "$SRC" if git clone -q "$DOTFILES_REPO" "$SRC"; then git -C "$SRC" config user.name "<your-git-name>" git -C "$SRC" config user.email "<your-git-email>" else rm -rf "$SRC" fi fi if [ -d "$SRC/.git" ]; then # Fast-forward only; a diverged history is left for the Stop sync to report. git -C "$SRC" pull -q --ff-only || true if store_empty; then # Fresh VM: restore the store and mark this commit as the sync base. chezmoi apply --force "$STORE" || true git -C "$SRC" rev-parse HEAD > "$BASEFILE" else # Existing store: three-way reconcile with upstream, then commit and push. bash "$SRC/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" </dev/null fi elif store_empty && checkout="$(attached_checkout)"; then # No clone yet: seed the store from the session's own dotfiles checkout. chezmoi apply --source "$checkout" --force "$STORE" || true fi # --- Link project memory dirs --------------------------------------------------- store_for_repo() { local repo repo="$(basename "$(git -C "$1" remote get-url origin 2>/dev/null)" .git)" [ -n "$repo" ] || repo="$(basename "$1")" case "$repo" in # Map any repo whose store name differs from the repo name, e.g.: # <repo-name>) printf '<store-name>' ;; *) printf '%s' "$repo" ;; esac } # A repo uses its own store; a parent dir (multi-repo session) uses its single # work repo's store, else dev-root. link_dir() { local dir="$1" top store repos project_dir top="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" if [ -n "$top" ]; then store="$(store_for_repo "$top")" else top="$dir" repos="$(find "$dir" -mindepth 2 -maxdepth 2 -name .git -printf '%h\n' 2>/dev/null | grep -v '/dotfiles$')" if [ "$(printf '%s' "$repos" | grep -c .)" = "1" ]; then store="$(store_for_repo "$repos")" else store="dev-root" fi fi [ "$store" = "dotfiles" ] && return 0 echo "link $top -> $store" >&2 # Claude Code names project dirs by replacing '/' and '.' with '-'. project_dir="$HOME/.claude/projects/$(printf '%s' "$top" | tr '/.' '-')" mkdir -p "$STORE/$store" "$project_dir" if [ -d "$project_dir/memory" ] && [ ! -L "$project_dir/memory" ]; then cp -Rn "$project_dir/memory"/. "$STORE/$store"/ 2>/dev/null || true rm -rf "$project_dir/memory" fi ln -sfn "$STORE/$store" "$project_dir/memory" } for d in "${dirs[@]}"; do link_dir "$d" done exit 0 EOF chmod +x "$HOOKS/cloud-memories-pull.sh" # --- Step 3: Settings: enable auto memory, register hooks ------------------ local sync="bash \$HOME/.local/share/chezmoi/private_dot_claude/hooks/executable_chezmoi-memories-sync.sh" local cfg cfg="$(jq -n --arg pull "$HOOKS/cloud-memories-pull.sh" --arg sync "$sync" '{ autoMemoryEnabled: true, hooks: { SessionStart: [{hooks: [{type: "command", command: $pull, timeout: 60}]}], PostToolUse: [{matcher: "Write|Edit", hooks: [{type: "command", timeout: 20, command: "jq -r '\''.tool_response.filePath // .tool_input.file_path // empty'\'' | { read -r f; [ -n \"$f\" ] || exit 0; r=\"$(cd \"$(dirname \"$f\")\" 2>/dev/null && pwd -P)/$(basename \"$f\")\"; case \"$r\" in \"$HOME\"/.claude/memories/*) [ -f \"$r\" ] && chezmoi add \"$r\";; esac; } || true"}]}], Stop: [{hooks: [{type: "command", command: $sync, timeout: 60, async: true}]}] }}')" || return 1 local settings="$HOME/.claude/settings.json" if [ -s "$settings" ]; then jq --argjson c "$cfg" '. * $c' "$settings" > "$settings.tmp" && mv "$settings.tmp" "$settings" else printf '%s\n' "$cfg" > "$settings" fi # --- Step 4: Restore and link now, before Claude starts -------------------- # Link the repos dir (multi-repo sessions) and each repo (single-repo sessions). local targets=(/home/user) local d for d in /home/user/*/; do [ -d "$d/.git" ] && targets+=("${d%/}") done "$HOOKS/cloud-memories-pull.sh" "${targets[@]}" </dev/null } setup || echo "cloud memory setup failed; continuing without synced memories" >&2 exit 0
So everything we’ve discussed so far deals with the actual sync lifecycle. But what about the memory lifecycle itself? As mentioned, claude doesn’t currently clean up old memories automatically, so I wanted a way to clean up irrelevant memories. For me the best way to do that has just been to write a skill and then hook it up to a routine, or fire it automatically.
Here’s what that skill looks like in my dotfiles:
--- name: memory-gc description: Use when asked to garbage-collect, verify, refresh, or clean up Claude memory files — stale status claims like "PR open", "not merged", "uncommitted", "in progress", "needs rerun" — or to back the memory store up to chezmoi. --- # Memory GC Verify every status-like claim in the memory store against the real world, correct what verification proves stale, and back the store up to chezmoi. Never guess: age alone never makes a claim stale — only a failed check does. ## Process 1. **Locate the store.** The project memory dir (`~/.claude/projects/<sanitized-path>/memory`) is a symlink into the canonical store `~/.claude/memories/<name>/`. Either path reaches the same files. 2. **Sweep.** Read MEMORY.md and every memory file. Collect claims that can go stale: PR/branch/merge state, "uncommitted", "staged", "in progress", "needs rerun", "still open", deploy status. 3. **Verify, read-only.** - GitHub: `gh pr view/list`, `gh api repos/<org>/<repo>/branches/<branch>`. - Local repos under the dev root: `git fetch`, then `git log`/`git grep` against `origin/main`, `git status`. - **Merge-state claims: the PR record is authoritative.** Resolve a branch's fate with `gh pr list --repo <org>/<repo> --state all --head <branch>` (and `--search "<topic>"`) BEFORE any code-level inference. A deleted remote branch, an unreachable commit, or a missing symbol name is NOT evidence of unmerged: branches get deleted after merge, squash-merge rewrites history, and later work renames symbols. Conclude "not merged" only when the PR record itself says so (no PR, or PR open/closed-unmerged). - Cluster/DB/infra runtime state (k8s jobs, pods, DB rows, Argo workflows): **do not verify** — no kubectl, no DB queries. Mark the claim "unverified (cluster state)" and keep it. 4. **Correct in place.** Prepend a dated `**STATUS (verified YYYY-MM-DD):** ...` line and surgically fix the sentences that verification disproved. The rest of the body survives: constraints, gotchas, file paths, deploy choreography, open questions, and **Why:** lines stay even when the headline status flips to shipped. Leave `[[wikilinks]]` untouched even when they don't resolve — dangling links are allowed by design. Do not condense or rewrite a memory wholesale unless <your-name> asks for that. 5. **Deletion needs confirmation.** A file is delete-eligible only when *nothing* in it is still true, actionable, or unverified. Collect candidates and ask <your-name> before removing any file. On approved deletion: remove the file, drop its MEMORY.md line, and `chezmoi forget --force` its path under `~/.claude/memories/`. 6. **Sync the index.** Every changed memory gets its MEMORY.md line refreshed; every memory file has exactly one index line; no line points at a missing file. 7. **Back up to chezmoi.**
--- name: memory-gc description: Use when asked to garbage-collect, verify, refresh, or clean up Claude memory files — stale status claims like "PR open", "not merged", "uncommitted", "in progress", "needs rerun" — or to back the memory store up to chezmoi. --- # Memory GC Verify every status-like claim in the memory store against the real world, correct what verification proves stale, and back the store up to chezmoi. Never guess: age alone never makes a claim stale — only a failed check does. ## Process 1. **Locate the store.** The project memory dir (`~/.claude/projects/<sanitized-path>/memory`) is a symlink into the canonical store `~/.claude/memories/<name>/`. Either path reaches the same files. 2. **Sweep.** Read MEMORY.md and every memory file. Collect claims that can go stale: PR/branch/merge state, "uncommitted", "staged", "in progress", "needs rerun", "still open", deploy status. 3. **Verify, read-only.** - GitHub: `gh pr view/list`, `gh api repos/<org>/<repo>/branches/<branch>`. - Local repos under the dev root: `git fetch`, then `git log`/`git grep` against `origin/main`, `git status`. - **Merge-state claims: the PR record is authoritative.** Resolve a branch's fate with `gh pr list --repo <org>/<repo> --state all --head <branch>` (and `--search "<topic>"`) BEFORE any code-level inference. A deleted remote branch, an unreachable commit, or a missing symbol name is NOT evidence of unmerged: branches get deleted after merge, squash-merge rewrites history, and later work renames symbols. Conclude "not merged" only when the PR record itself says so (no PR, or PR open/closed-unmerged). - Cluster/DB/infra runtime state (k8s jobs, pods, DB rows, Argo workflows): **do not verify** — no kubectl, no DB queries. Mark the claim "unverified (cluster state)" and keep it. 4. **Correct in place.** Prepend a dated `**STATUS (verified YYYY-MM-DD):** ...` line and surgically fix the sentences that verification disproved. The rest of the body survives: constraints, gotchas, file paths, deploy choreography, open questions, and **Why:** lines stay even when the headline status flips to shipped. Leave `[[wikilinks]]` untouched even when they don't resolve — dangling links are allowed by design. Do not condense or rewrite a memory wholesale unless <your-name> asks for that. 5. **Deletion needs confirmation.** A file is delete-eligible only when *nothing* in it is still true, actionable, or unverified. Collect candidates and ask <your-name> before removing any file. On approved deletion: remove the file, drop its MEMORY.md line, and `chezmoi forget --force` its path under `~/.claude/memories/`. 6. **Sync the index.** Every changed memory gets its MEMORY.md line refreshed; every memory file has exactly one index line; no line points at a missing file. 7. **Back up to chezmoi.**
--- name: memory-gc description: Use when asked to garbage-collect, verify, refresh, or clean up Claude memory files — stale status claims like "PR open", "not merged", "uncommitted", "in progress", "needs rerun" — or to back the memory store up to chezmoi. --- # Memory GC Verify every status-like claim in the memory store against the real world, correct what verification proves stale, and back the store up to chezmoi. Never guess: age alone never makes a claim stale — only a failed check does. ## Process 1. **Locate the store.** The project memory dir (`~/.claude/projects/<sanitized-path>/memory`) is a symlink into the canonical store `~/.claude/memories/<name>/`. Either path reaches the same files. 2. **Sweep.** Read MEMORY.md and every memory file. Collect claims that can go stale: PR/branch/merge state, "uncommitted", "staged", "in progress", "needs rerun", "still open", deploy status. 3. **Verify, read-only.** - GitHub: `gh pr view/list`, `gh api repos/<org>/<repo>/branches/<branch>`. - Local repos under the dev root: `git fetch`, then `git log`/`git grep` against `origin/main`, `git status`. - **Merge-state claims: the PR record is authoritative.** Resolve a branch's fate with `gh pr list --repo <org>/<repo> --state all --head <branch>` (and `--search "<topic>"`) BEFORE any code-level inference. A deleted remote branch, an unreachable commit, or a missing symbol name is NOT evidence of unmerged: branches get deleted after merge, squash-merge rewrites history, and later work renames symbols. Conclude "not merged" only when the PR record itself says so (no PR, or PR open/closed-unmerged). - Cluster/DB/infra runtime state (k8s jobs, pods, DB rows, Argo workflows): **do not verify** — no kubectl, no DB queries. Mark the claim "unverified (cluster state)" and keep it. 4. **Correct in place.** Prepend a dated `**STATUS (verified YYYY-MM-DD):** ...` line and surgically fix the sentences that verification disproved. The rest of the body survives: constraints, gotchas, file paths, deploy choreography, open questions, and **Why:** lines stay even when the headline status flips to shipped. Leave `[[wikilinks]]` untouched even when they don't resolve — dangling links are allowed by design. Do not condense or rewrite a memory wholesale unless <your-name> asks for that. 5. **Deletion needs confirmation.** A file is delete-eligible only when *nothing* in it is still true, actionable, or unverified. Collect candidates and ask <your-name> before removing any file. On approved deletion: remove the file, drop its MEMORY.md line, and `chezmoi forget --force` its path under `~/.claude/memories/`. 6. **Sync the index.** Every changed memory gets its MEMORY.md line refreshed; every memory file has exactly one index line; no line points at a missing file. 7. **Back up to chezmoi.**
Never chezmoi add through the ~/.claude/projects/... symlink path — that creates a duplicate source entry outside private_dot_claude/memories/. Never git add -A in the chezmoi repo — it sweeps unrelated pending dotfile changes into the commit. 8. Report. One row per checked claim: memory → claim → verdict (fresh / stale→fixed / unverified (cluster state) / delete-candidate), then the chezmoi commit hash.
Rules
Temptation | Instead |
|---|---|
Delete a “mostly done” memory | Keep it if any follow-up, gotcha, or unverified question remains; deleting always requires <your-name>'s confirmation first |
Condense a long memory while updating it | Status line + surgical edits only; condensation is a separate, explicitly requested task |
Treat an old date as staleness | Verify or leave it; unverifiable ≠ stale |
Check k8s/DB/Argo state to close a claim | Out of scope for GC — mark “unverified (cluster state)”, keep the claim |
Fix dangling | Leave them; they mark memories worth writing later |
And that's it! That's my full e2e system for unifying claude memories across machines and keeping them in sync. Thanks for coming to my TED talk and happy coding!
So everything we’ve discussed so far deals with the actual sync lifecycle. But what about the memory lifecycle itself? As mentioned, claude doesn’t currently clean up old memories automatically, so I wanted a way to clean up irrelevant memories. For me the best way to do that has just been to write a skill and then hook it up to a routine, or fire it automatically.
Here’s what that skill looks like in my dotfiles:
--- name: memory-gc description: Use when asked to garbage-collect, verify, refresh, or clean up Claude memory files — stale status claims like "PR open", "not merged", "uncommitted", "in progress", "needs rerun" — or to back the memory store up to chezmoi. --- # Memory GC Verify every status-like claim in the memory store against the real world, correct what verification proves stale, and back the store up to chezmoi. Never guess: age alone never makes a claim stale — only a failed check does. ## Process 1. **Locate the store.** The project memory dir (`~/.claude/projects/<sanitized-path>/memory`) is a symlink into the canonical store `~/.claude/memories/<name>/`. Either path reaches the same files. 2. **Sweep.** Read MEMORY.md and every memory file. Collect claims that can go stale: PR/branch/merge state, "uncommitted", "staged", "in progress", "needs rerun", "still open", deploy status. 3. **Verify, read-only.** - GitHub: `gh pr view/list`, `gh api repos/<org>/<repo>/branches/<branch>`. - Local repos under the dev root: `git fetch`, then `git log`/`git grep` against `origin/main`, `git status`. - **Merge-state claims: the PR record is authoritative.** Resolve a branch's fate with `gh pr list --repo <org>/<repo> --state all --head <branch>` (and `--search "<topic>"`) BEFORE any code-level inference. A deleted remote branch, an unreachable commit, or a missing symbol name is NOT evidence of unmerged: branches get deleted after merge, squash-merge rewrites history, and later work renames symbols. Conclude "not merged" only when the PR record itself says so (no PR, or PR open/closed-unmerged). - Cluster/DB/infra runtime state (k8s jobs, pods, DB rows, Argo workflows): **do not verify** — no kubectl, no DB queries. Mark the claim "unverified (cluster state)" and keep it. 4. **Correct in place.** Prepend a dated `**STATUS (verified YYYY-MM-DD):** ...` line and surgically fix the sentences that verification disproved. The rest of the body survives: constraints, gotchas, file paths, deploy choreography, open questions, and **Why:** lines stay even when the headline status flips to shipped. Leave `[[wikilinks]]` untouched even when they don't resolve — dangling links are allowed by design. Do not condense or rewrite a memory wholesale unless <your-name> asks for that. 5. **Deletion needs confirmation.** A file is delete-eligible only when *nothing* in it is still true, actionable, or unverified. Collect candidates and ask <your-name> before removing any file. On approved deletion: remove the file, drop its MEMORY.md line, and `chezmoi forget --force` its path under `~/.claude/memories/`. 6. **Sync the index.** Every changed memory gets its MEMORY.md line refreshed; every memory file has exactly one index line; no line points at a missing file. 7. **Back up to chezmoi.**
Never chezmoi add through the ~/.claude/projects/... symlink path — that creates a duplicate source entry outside private_dot_claude/memories/. Never git add -A in the chezmoi repo — it sweeps unrelated pending dotfile changes into the commit. 8. Report. One row per checked claim: memory → claim → verdict (fresh / stale→fixed / unverified (cluster state) / delete-candidate), then the chezmoi commit hash.
Rules
Temptation | Instead |
|---|---|
Delete a “mostly done” memory | Keep it if any follow-up, gotcha, or unverified question remains; deleting always requires <your-name>'s confirmation first |
Condense a long memory while updating it | Status line + surgical edits only; condensation is a separate, explicitly requested task |
Treat an old date as staleness | Verify or leave it; unverifiable ≠ stale |
Check k8s/DB/Argo state to close a claim | Out of scope for GC — mark “unverified (cluster state)”, keep the claim |
Fix dangling | Leave them; they mark memories worth writing later |
And that's it! That's my full e2e system for unifying claude memories across machines and keeping them in sync. Thanks for coming to my TED talk and happy coding!
FAQ
#1 New AI Software Globally - G2 2026
Enterprise-grade security
Trusted by world-class engineering teams
Recommended
Subscribe to our engineering newsletter
Be the first to know about Leo's newest capabilities and get practical tips to boost your engineering.
Need help? Join the Leo AI Community
Connect with other engineers, get answers from our team, and request features.



#1 New Software
Globally
All Industries
#12 AI Tool
Worldwide
G2 2026
© 2026 Leo AI, Inc.
Be the first to know about Leo's newest capabilities and get practical tips to boost your engineering.
Connect with other engineers, get answers from our team, and request features.



#1 New Software
Globally
All Industries
#12 AI Tool
Worldwide
G2 2026
© 2026 Leo AI, Inc.
Subscribe to our engineering newsletter
Be the first to know about Leo's newest capabilities and get practical tips to boost your engineering.
Need help? Join the Leo AI Community
Connect with other engineers, get answers from our team, and request features.



#1 New Software
Globally
All Industries
#12 AI Tool
Worldwide
G2 2026
© 2026 Leo AI, Inc.
Subscribe to our engineering newsletter
Be the first to know about Leo's newest capabilities and get practical tips to boost your engineering.
Need help? Join the Leo AI Community
Connect with other engineers, get answers from our team, and request features.



#1 New Software
Globally
All Industries
#12 AI Tool
Worldwide
G2 2026
© 2026 Leo AI, Inc.