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.