Claude Code keeps two things on my laptop that took months to build up and would take months to rebuild. Until recently, neither one was backed up.
The first is memory. Every project gets a directory under ~/.claude/projects/, and in it Claude writes one Markdown file per thing it has learned: how I want pull requests opened, which CLI profile is read-only, which approach I rejected and why. An index file ties them together. Mine is at 351 files across 25 projects. That is 351 corrections I do not want to make twice.
The second is skills. A skill is a directory under ~/.claude/skills/ holding a SKILL.md and usually a few scripts beside it. I have ten. One is the writing voice skill this post was drafted with. The rest are work-specific and took real iteration to get right.
Both live in a dot directory in my home folder. This machine has no Time Machine. The directory is not in git. One bad rm -rf or one dead SSD and it is all gone.
Why not the dotfiles repo
My dotfiles are on GitHub, and that is where I first put the skills. The repo is public.
The skills are not secrets. There are no keys in them. But they describe how my employer’s systems fit together and how I work inside them, and that is not mine to publish. The memory files are worse, because Claude writes down whatever it learns and it learns a lot. So I ended up with a few skills committed and the rest deliberately left untracked, which is a backup that covers exactly the things I care least about.
A private repo fixes the audience but not the mechanism. A backup I have to remember to commit is a TODO list, and memory is not a system.
Obsidian was already doing the job
I already run Obsidian, and Obsidian Sync already pays for an off-device copy of my vault with version history. I did not want a second sync tool, a cron job, or a cloud bucket. I wanted the thing that already works to cover two more folders.
So the memory and skills directories now live inside the vault, and Claude Code gets a symlink pointing at them.
~/.claude/projects/<slug>/memory -> ~/Documents/vault/AI/memories/<slug>/
~/.claude/skills -> ~/Documents/vault/AI/skills/
Claude Code follows the symlink and never notices. Obsidian sees an ordinary folder and syncs it. Nobody has to remember anything, which is the only kind of backup I trust.
The Taskfile
Moving a directory out from under a running tool is the one part that needs care, so I put it in a Taskfile in my home directory. Four tasks per thing: link, status, unlink, and mirror.
# Claude Code memory + skills <-> Obsidian vault
#
# Two things under ~/.claude have no other backup (not in Time Machine, and the
# dotfiles repo is public so work content can't go there):
#
# memory ~/.claude/projects/<slug>/memory/ -> vault AI/memories/<slug>/
# skills ~/.claude/<dir>/ (skills, commands) -> vault AI/<dir>/
#
# `task memory:link` / `task skills:link` move the real directory into the vault
# and leave a symlink behind, so Obsidian Sync backs it up continuously.
# On a new machine: install Obsidian, let Sync pull the vault, then run both
# link tasks again to recreate the symlinks.
#
# Fallback if symlinks ever misbehave: `task <x>:unlink` puts real directories
# back, then `task <x>:mirror` does a manual one-way rsync.
#
# ~/.claude/commands currently symlinks into the public dotfiles repo, so
# skills:link leaves it alone (skills:status shows ELSEWHERE) and skills:mirror
# copies its contents into the vault as a point-in-time backup. To move it into
# the vault for good:
# task skills:mirror && rm ~/.claude/commands && task skills:link
version: '3'
silent: true
vars:
VAULT: '{{.HOME}}/Documents/vault/AI/memories'
PROJECTS: '{{.HOME}}/.claude/projects'
AI_VAULT: '{{.HOME}}/Documents/vault/AI'
CLAUDE_HOME: '{{.HOME}}/.claude'
CLAUDE_DIRS: 'skills commands'
tasks:
default:
cmds:
- task --list
memory:link:
desc: Move Claude memory dirs into the vault and symlink them back (idempotent; also restores on a new machine)
cmds:
- |
set -eu
mkdir -p "{{.VAULT}}"
# 1. Migrate: real memory dirs -> vault, leave symlink behind
for p in "{{.PROJECTS}}"/*/; do
slug=$(basename "$p"); src="${p%/}/memory"; dst="{{.VAULT}}/$slug"
[ -L "$src" ] && continue
[ -d "$src" ] || continue
mkdir -p "$dst"
rsync -a "$src/" "$dst/"
mv "$src" "$src.migrating"
if [ -e "$src" ]; then rsync -a "$src/" "$dst/"; rm -rf "$src"; fi # write landed mid-move
ln -s "$dst" "$src"
rsync -au "$src.migrating/" "$dst/" # catch stragglers
rm -rf "$src.migrating"
echo "migrated $slug"
done
# 2. Restore: vault dirs with no project symlink yet
for d in "{{.VAULT}}"/*/; do
slug=$(basename "$d"); src="{{.PROJECTS}}/$slug/memory"
[ -e "$src" ] && continue
mkdir -p "{{.PROJECTS}}/$slug"
ln -s "${d%/}" "$src"
echo "restored $slug"
done
memory:status:
desc: Show which memory dirs are symlinked into the vault, and any that are not
cmds:
- |
for p in "{{.PROJECTS}}"/*/; do
slug=$(basename "$p"); src="${p%/}/memory"
[ -e "$src" ] || [ -L "$src" ] || continue
n=$(find "$src/" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')
if [ -L "$src" ]; then
case "$(readlink "$src")" in
"{{.VAULT}}"/*) [ -d "$src/" ] && printf 'ok %4s %s\n' "$n" "$slug" || printf 'BROKEN %4s %s\n' "$n" "$slug" ;;
*) printf 'ELSEWHERE %4s %s -> %s\n' "$n" "$slug" "$(readlink "$src")" ;;
esac
else
printf 'UNLINKED %4s %s\n' "$n" "$slug"
fi
done
for d in "{{.VAULT}}"/*/; do
slug=$(basename "$d")
[ -e "{{.PROJECTS}}/$slug/memory" ] || printf 'VAULT-ONLY %s\n' "$slug"
done
memory:unlink:
desc: Replace each symlink with a real directory copied from the vault (fallback path)
cmds:
- |
set -eu
for p in "{{.PROJECTS}}"/*/; do
slug=$(basename "$p"); src="${p%/}/memory"
[ -L "$src" ] || continue
target=$(readlink "$src")
rm "$src"; mkdir -p "$src"
rsync -a "$target/" "$src/"
echo "unlinked $slug"
done
memory:mirror:
desc: Manual one-way rsync of real (non-symlinked) memory dirs into the vault, mirroring deletions
cmds:
- |
set -eu
mkdir -p "{{.VAULT}}"
for p in "{{.PROJECTS}}"/*/; do
slug=$(basename "$p"); src="${p%/}/memory"; dst="{{.VAULT}}/$slug"
[ -d "$src" ] || continue
[ -L "$src" ] && continue
[ -n "$(ls -A "$src")" ] || continue
mkdir -p "$dst"
rsync -a --delete "$src/" "$dst/"
echo "mirrored $slug"
done
skills:link:
desc: Move ~/.claude/{skills,commands} into the vault (AI/<dir>) and symlink them back (idempotent; also restores on a new machine)
cmds:
- |
set -eu
mkdir -p "{{.AI_VAULT}}" "{{.CLAUDE_HOME}}"
for name in {{.CLAUDE_DIRS}}; do
src="{{.CLAUDE_HOME}}/$name"; dst="{{.AI_VAULT}}/$name"
if [ -L "$src" ]; then
[ "$(readlink "$src")" = "$dst" ] || echo "skipped $name -> $(readlink "$src") (symlink elsewhere; not touching it)"
continue
fi
if [ -d "$src" ]; then
# Migrate: real dir -> vault, leave symlink behind
mkdir -p "$dst"
rsync -a --exclude .DS_Store "$src/" "$dst/"
mv "$src" "$src.migrating"
if [ -e "$src" ]; then rsync -a --exclude .DS_Store "$src/" "$dst/"; rm -rf "$src"; fi # write landed mid-move
ln -s "$dst" "$src"
rsync -au --exclude .DS_Store "$src.migrating/" "$dst/" # catch stragglers
rm -rf "$src.migrating"
echo "migrated $name"
elif [ -d "$dst" ]; then
# Restore: vault has it, ~/.claude does not
ln -s "$dst" "$src"
echo "restored $name"
fi
done
skills:status:
desc: Show whether each ~/.claude dir (skills, commands) is symlinked into the vault
cmds:
- |
for name in {{.CLAUDE_DIRS}}; do
src="{{.CLAUDE_HOME}}/$name"; dst="{{.AI_VAULT}}/$name"
n=$(find "$src/" -type f ! -name .DS_Store 2>/dev/null | wc -l | tr -d ' ')
if [ -L "$src" ]; then
if [ "$(readlink "$src")" = "$dst" ]; then
[ -d "$src/" ] && printf 'ok %4s %s\n' "$n" "$name" || printf 'BROKEN %4s %s\n' "$n" "$name"
else
extra=""
if [ -d "$dst" ]; then
pending=$(rsync -ani --delete --exclude .DS_Store "$src/" "$dst/" | grep -vc '^\.' || true)
[ "$pending" = 0 ] && extra=" [vault copy in sync]" || extra=" [vault copy STALE: $pending changes; run: task skills:mirror]"
fi
printf 'ELSEWHERE %4s %s -> %s%s\n' "$n" "$name" "$(readlink "$src")" "$extra"
fi
elif [ -d "$src" ]; then
printf 'UNLINKED %4s %s\n' "$n" "$name"
elif [ -d "$dst" ]; then
printf 'VAULT-ONLY %s\n' "$name"
fi
done
skills:unlink:
desc: Replace each vault symlink under ~/.claude with a real directory copied from the vault (fallback path)
cmds:
- |
set -eu
for name in {{.CLAUDE_DIRS}}; do
src="{{.CLAUDE_HOME}}/$name"; dst="{{.AI_VAULT}}/$name"
[ -L "$src" ] || continue
[ "$(readlink "$src")" = "$dst" ] || continue # leave symlinks that point elsewhere (e.g. dotfiles) alone
rm "$src"; mkdir -p "$src"
rsync -a "$dst/" "$src/"
echo "unlinked $name"
done
skills:mirror:
desc: Manual one-way rsync of non-vault ~/.claude dirs (real dirs, or symlinks into dotfiles) into the vault, mirroring deletions
cmds:
- |
set -eu
mkdir -p "{{.AI_VAULT}}"
for name in {{.CLAUDE_DIRS}}; do
src="{{.CLAUDE_HOME}}/$name"; dst="{{.AI_VAULT}}/$name"
[ -d "$src/" ] || continue
[ -L "$src" ] && [ "$(readlink "$src")" = "$dst" ] && continue # already lives in the vault
[ -n "$(ls -A "$src/")" ] || continue
mkdir -p "$dst"
rsync -a --delete --exclude .DS_Store "$src/" "$dst/"
echo "mirrored $name"
done
link is the one that matters. It rsyncs the real directory into the vault, moves the original aside, drops the symlink in its place, and then rsyncs the moved-aside copy one more time to catch anything Claude wrote during the move. Only then does it delete the original. It is idempotent, so it doubles as the restore step: on a new machine, install Obsidian, let Sync pull the vault, run task memory:link and task skills:link, and Claude Code picks up where it left off.
status tells me whether each path is a symlink into the vault, a real directory that still needs linking, or a link pointing somewhere else entirely.
$ task skills:status
ok 19 skills
ELSEWHERE 8 commands -> ~/.dotfiles/claude/.claude/commands [vault copy in sync]
That second line is honest about the one thing I have not moved yet. My older slash commands still symlink into the public dotfiles repo. mirror copies them into the vault as a point-in-time backup, and status compares the two with an rsync dry run and says STALE when I have edited one without re-running mirror. Moving them for good is one line, and I will get to it.
unlink is the escape hatch. If symlinks ever misbehave it puts a real directory back, copied from the vault, and mirror becomes a manual one-way sync.
Three things that would have bitten me
Obsidian treats everything as notes. Memory files link to each other with [[wikilinks]], and clicking one Obsidian cannot resolve creates a stray empty note. Skills carry templates and scripts that have no business in search results. Both folders go in Obsidian’s Excluded files list, under Settings -> Files and links. Exclusion only affects search, graph, and link suggestions. Sync still carries the files.
Obsidian Sync does not sync every file type by default. It syncs Markdown and whatever types you toggle on. Six of my nineteen skill files are .py, .sh, .js, or .html. With “Sync all other types” off, those never leave the machine and the backup is silently partial. Turn it on before you trust it.
Deleting through the link deletes from the vault. rm -rf ~/.claude/skills/old-skill removes AI/skills/old-skill, and Sync then faithfully removes it from every other device. Version history would get it back, but I would rather not find out how well.
What it costs
One Taskfile. Two symlinks per machine. No new accounts, agents, or schedules.
Boring on purpose.