Created
June 14, 2026 21:01
-
-
Save apage43/f2c14be6fc863c97c64723d2556191f9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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