Skip to content

Instantly share code, notes, and snippets.

@apage43
Created June 14, 2026 21:01
Show Gist options
  • Select an option

  • Save apage43/f2c14be6fc863c97c64723d2556191f9 to your computer and use it in GitHub Desktop.

Select an option

Save apage43/f2c14be6fc863c97c64723d2556191f9 to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.12"]
# ///
#
# membounce — bounce this project's Claude Code memories to/from a secret gist.
# ============================================================================
#
# Claude Code keeps per-project "memories" as flat markdown files under
# ~/.claude/projects/<slug>/memory/
# where <slug> is the project's absolute path with "/" and "." turned into "-".
# This script syncs that folder with a GitHub gist so you can carry your
# memories between machines.
#
# Requirements: `uv`, the GitHub CLI (`gh`, logged in via `gh auth login`),
# `git`, and — for conflict resolution — `claude`.
#
# USAGE
# -----
# ./membounce.py
# No args. Creates a NEW *secret* gist containing every memory file for
# the project in the CURRENT directory. Asks for confirmation first,
# then prints the gist URL. Save that URL — you pass it back in to sync.
#
# ./membounce.py <gist-url>
# Full two-way sync against an existing gist:
# 1. clone/refresh a working copy of the gist (cached under
# ~/.cache/membounce/<gist-id>)
# 2. copy your current local memories in and commit them
# 3. `git pull --rebase` to fold in whatever the gist gained elsewhere
# 4. on conflict, hand off to an INTERACTIVE `claude` session to merge
# (interactive on purpose — never `-p`, so it never burns API credits)
# 5. `git push` the reconciled result back to the gist
# 6. copy the merged memories back down to your local memory folder
#
# Sync is a UNION merge: files are added/updated in both directions, never
# deleted. A machine with fewer memories can't wipe the gist; deletions simply
# don't propagate (remove a memory from the gist by hand if you really mean it).
# Two machines that edited the SAME file produce a rebase conflict, which is
# where the interactive claude hand-off comes in.
#
# Run from inside the project whose memories you want to sync (the project is
# inferred from the current working directory).
#
# # examples
# cd ~/code/myproject
# ./membounce.py # create the gist
# ./membounce.py https://gist.github.com/you/abc123 # sync against it
#
# Options: --memory-dir PATH overrides where memories are read/written.
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(add_completion=False)
CACHE_ROOT = Path.home() / ".cache" / "membounce"
# Use gh's token as a git credential helper so clone/fetch/push work over HTTPS
# without any SSH key setup, headless or interactive.
GH_CRED = "!gh auth git-credential"
# --- small process helpers -------------------------------------------------
def capture(cmd: list[str], cwd: Optional[Path] = None, check: bool = True) -> str:
"""Run a command, return its stdout (stderr passes through to the terminal)."""
res = subprocess.run(
cmd, cwd=cwd, check=check, text=True,
stdout=subprocess.PIPE,
)
return res.stdout.strip()
def stream(cmd: list[str], cwd: Optional[Path] = None) -> int:
"""Run a command with stdio inherited (for interactive/visible output)."""
return subprocess.run(cmd, cwd=cwd).returncode
def git(args: list[str], cwd: Path, check: bool = True) -> int:
return stream(["git", "-C", str(cwd), *args])
# --- memory + gist plumbing ------------------------------------------------
def project_memory_dir() -> Path:
"""Where Claude Code stores memories for the project in the current dir."""
cwd = Path.cwd().resolve()
slug = str(cwd).replace("/", "-").replace(".", "-")
return Path.home() / ".claude" / "projects" / slug / "memory"
def memory_files(mem_dir: Path) -> list[Path]:
if not mem_dir.is_dir():
return []
return sorted(p for p in mem_dir.iterdir() if p.is_file())
def gist_id_from_url(url: str) -> str:
"""Pull the gist id out of a URL, owner/id, or bare id."""
cleaned = url.strip().rstrip("/")
if cleaned.endswith(".git"):
cleaned = cleaned[: -len(".git")]
return cleaned.split("/")[-1]
def copy_into(src: Path, dst: Path) -> None:
"""Copy src's regular files into dst (add/update). Never deletes.
Union semantics on purpose: syncing must never let one machine wipe a
memory that lives on another. Deletions therefore don't propagate — to
drop a memory, remove it from the gist (or everywhere) by hand.
"""
dst.mkdir(parents=True, exist_ok=True)
for p in src.iterdir():
if p.is_file():
shutil.copy2(p, dst / p.name)
def rebase_in_progress(repo: Path) -> bool:
git_dir = Path(capture(["git", "-C", str(repo), "rev-parse", "--absolute-git-dir"]))
return (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists()
def has_changes(repo: Path) -> bool:
out = capture(["git", "-C", str(repo), "status", "--porcelain"])
return bool(out)
# --- the two flows ---------------------------------------------------------
def create_gist(mem_dir: Path) -> None:
files = memory_files(mem_dir)
if not files:
typer.secho(
f"No memory files found in {mem_dir}\n"
"Nothing to put in a gist yet — give Claude something to remember first.",
fg=typer.colors.YELLOW,
)
raise typer.Exit(1)
project = Path.cwd().name
typer.secho(f"Project: {project}", bold=True)
typer.echo(f"Memory dir: {mem_dir}")
typer.echo(f"Found {len(files)} memory file(s):")
for f in files:
typer.echo(f" • {f.name}")
if not typer.confirm("\nCreate a new SECRET gist with these files?", default=True):
typer.secho("Aborted.", fg=typer.colors.RED)
raise typer.Exit(1)
# gists are secret by default; -p would make them public, so we just omit it.
cmd = [
"gh", "gist", "create",
"-d", f"Claude Code memories — {project}",
*[str(f) for f in files],
]
url = capture(cmd)
typer.secho("\nCreated secret gist:", fg=typer.colors.GREEN, bold=True)
typer.echo(f" {url}")
typer.echo("\nSync later with:")
typer.secho(f" {sys.argv[0]} {url}", fg=typer.colors.CYAN)
def sync_gist(gist_url: str, mem_dir: Path) -> None:
gid = gist_id_from_url(gist_url)
clone = CACHE_ROOT / gid
# 1. clone or refresh the working copy
if (clone / ".git").is_dir():
typer.echo(f"Refreshing gist working copy: {clone}")
git(["fetch", "origin"], clone)
else:
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
if clone.exists():
shutil.rmtree(clone)
typer.echo(f"Cloning gist {gid} → {clone}")
clone_url = f"https://gist.github.com/{gid}.git"
rc = stream(["git", "-c", f"credential.helper={GH_CRED}", "clone", clone_url, str(clone)])
if rc != 0:
typer.secho("Failed to clone gist.", fg=typer.colors.RED)
raise typer.Exit(1)
# persist the gh credential helper so fetch/pull/push keep working
git(["config", "credential.helper", GH_CRED], clone)
branch = capture(["git", "-C", str(clone), "branch", "--show-current"]) or "master"
# 2. copy local memories in and commit
mem_dir.mkdir(parents=True, exist_ok=True)
copy_into(mem_dir, clone)
git(["add", "-A"], clone)
if has_changes(clone):
typer.echo("Committing local memory changes…")
git(["commit", "-m", f"membounce: local sync from {Path.cwd().name}"], clone)
else:
typer.echo("No local changes to commit.")
# 3. pull --rebase
typer.echo("Rebasing onto gist…")
rc = git(["pull", "--rebase", "origin", branch], clone, check=False)
# 4. resolve conflicts interactively with claude
if rc != 0 and rebase_in_progress(clone):
typer.secho(
"\nRebase hit conflicts — launching an interactive claude session to resolve.\n"
"(interactive on purpose; this never uses -p / API credits)",
fg=typer.colors.YELLOW,
)
prompt = (
"This directory is a git repo (a GitHub gist of Claude Code memory files) "
"in the middle of a `git pull --rebase` with conflicts. Each file is a "
"markdown memory. Resolve every conflict by KEEPING ALL distinct memories "
"from both sides (merge, never drop content), then `git add` the resolved "
"files and run `git rebase --continue` until the rebase is fully complete. "
"Do not push."
)
stream(["claude", prompt], cwd=clone)
if rebase_in_progress(clone):
typer.secho(
"\nRebase is still in progress — aborting before push so nothing breaks.\n"
f"Finish it yourself in {clone} (git rebase --continue / --abort), then re-run.",
fg=typer.colors.RED,
)
raise typer.Exit(1)
elif rc != 0:
typer.secho("git pull --rebase failed (not a conflict). Aborting.", fg=typer.colors.RED)
raise typer.Exit(1)
# 5. push the reconciled result
typer.echo("Pushing to gist…")
if git(["push", "origin", branch], clone, check=False) != 0:
typer.secho("Push failed. Resolve manually in " + str(clone), fg=typer.colors.RED)
raise typer.Exit(1)
# 6. bring the merged result back down locally
copy_into(clone, mem_dir)
typer.secho("\nSynced. Local memories are up to date with the gist.", fg=typer.colors.GREEN, bold=True)
# --- entrypoint ------------------------------------------------------------
@app.command()
def main(
gist_url: Optional[str] = typer.Argument(
None, help="Gist URL to sync against. Omit to create a new secret gist."
),
memory_dir: Optional[Path] = typer.Option(
None, "--memory-dir", help="Override the memory directory to read/write."
),
) -> None:
"""Bounce this project's Claude Code memories to/from a secret gist."""
mem_dir = memory_dir.expanduser().resolve() if memory_dir else project_memory_dir()
if gist_url is None:
create_gist(mem_dir)
else:
sync_gist(gist_url, mem_dir)
if __name__ == "__main__":
app()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment