Python 3 · no dependencies · MIT

claude-memory-keeper

Claude Code files each project's memory under a key derived from the folder's absolute path. Move or rename the folder and the key changes, so the memory is orphaned — not deleted, just unreachable. This gives every project a stable id that travels inside the folder, and reconnects everything the moment you say so.

Everything you need is on this page: copy the code straight out of the blocks, or download the files. Nothing to install beyond Python 3, which macOS already ships. The script's comments and CLI output are in Spanish.


What breaks when you move a folder

Three separate things are tied to the absolute path, in three different places. A single rename in Finder breaks all of them at once.

The key is just the path with every non-alphanumeric character turned into a dash:

Folder on diskKey under ~/.claude/projects/
/Users/you/Developer/my-app-Users-you-Developer-my-app
/Users/you/Projects/Estadística-Users-you-Projects-Estad-stica

How it works

Two pieces. A hidden .claude-project-id file at the project root holds an id that never changes and moves with the folder. A central ~/.claude/project-registry.json remembers, per id, where it last lived and every move it has made. Like an identity document: you change address, your id doesn't, you just update the registry.

A SessionStart hook stamps the id the first time you open Claude Code in a folder, so projects enrol themselves before it ever occurs to you to move them. From there, reconciling is three steps:

  1. 01

    Scan

    Walk the watched roots looking for .claude-project-id files.

  2. 02

    Compare

    For each id, check where it is now against where the registry said it was.

  3. 03

    Reconnect

    If they differ, the folder moved: copy the memory to the new key, rewrite the transcripts, migrate the config, update the registry.


Install

01Get the engine

Download it or copy the source, and save it as ~/.claude/scripts/claude_projects.py.

claude_projects.pyDownload ↓
#!/usr/bin/env python3
"""
claude_projects.py — Sistema de "DNI" para proyectos de Claude Code.

Problema: Claude Code guarda memoria e historial indexados por la RUTA ABSOLUTA
de la carpeta (~/.claude/projects/<clave>/). Si movés/renombrás la carpeta, la
clave cambia y la memoria queda "huérfana".

Solución: cada proyecto lleva un archivo oculto `.claude-project-id` (su DNI, un
id estable que viaja DENTRO de la carpeta) y hay un registro central
(~/.claude/project-registry.json) que recuerda dónde estaba cada DNI. Comparando
"dónde está el DNI ahora" vs "dónde decía el registro" se detecta el movimiento y
se reconecta la memoria a la clave nueva, en forma automática.

Subcomandos:
  enroll <ruta>   Estampa el DNI en esa carpeta (si falta), la registra y —si se
                  movió— reconecta su memoria. Rápido, para el hook SessionStart.
  reconcile       Escanea las carpetas raíz, enrola proyectos nuevos y reconecta
                  todos los que se hayan movido (memoria + .jsonl con cwd reescrito
                  + config de ~/.claude.json). Para "moví varias cosas, ordená".
  sync-config     Migra la config por-proyecto de ~/.claude.json (MCP, permisos,
                  confianza) a la ruta nueva. No destructivo. Efecto al reiniciar.
  prune-config    Limpia entradas huérfanas de ~/.claude.json ya migradas o sin config.
  status          Muestra el registro (qué proyecto vive dónde + historial).

Config (dentro del registro, editable): _config.roots = carpetas a vigilar.
"""
import sys, os, json, time, uuid, shutil, unicodedata
from datetime import datetime
from pathlib import Path

HOME = Path.home()
PROJECTS = HOME / ".claude" / "projects"
ARCHIVE  = HOME / ".claude" / "projects-archive"
REGISTRY = HOME / ".claude" / "project-registry.json"
CLAUDE_JSON = HOME / ".claude.json"   # config global por-proyecto (indexada por ruta): MCP, permisos, confianza
MARKER   = ".claude-project-id"
# Claves de config por-proyecto que hay que migrar cuando una carpeta se mueve.
CONFIG_KEYS = ("mcpServers", "enabledMcpjsonServers", "disabledMcpjsonServers",
               "allowedTools", "hasTrustDialogAccepted")
DEFAULT_ROOTS = [str(HOME / "Developer"), str(HOME / "Projects")]  # editá según tus carpetas madre
PRUNE = {"node_modules", ".git", ".venv", "venv", "__pycache__", ".next",
         "dist", "build", ".cache", "Library", ".Trash"}
MAX_DEPTH = 3  # profundidad de escaneo bajo cada raíz


def enc(path: str) -> str:
    """Ruta -> clave, EXACTAMENTE como Claude Code en macOS: normaliza a NFC y
    reemplaza por '-' todo lo que no sea ASCII alfanumérico (los acentos también:
    'Estadística' -> 'Estad-stica'). macOS guarda nombres en NFD, por eso el
    normalize('NFC') es imprescindible para que la clave coincida."""
    path = unicodedata.normalize("NFC", path)
    return "".join(c if (c.isascii() and c.isalnum()) else "-" for c in path)

def now() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M")

def load_registry() -> dict:
    if REGISTRY.exists():
        try:
            data = json.loads(REGISTRY.read_text(encoding="utf-8"))
        except Exception:
            data = {}
    else:
        data = {}
    data.setdefault("_config", {"roots": DEFAULT_ROOTS, "version": 1})
    data.setdefault("projects", {})
    return data

def save_registry(reg: dict):
    REGISTRY.write_text(json.dumps(reg, indent=2, ensure_ascii=False), encoding="utf-8")

def read_marker(folder: Path):
    m = folder / MARKER
    if not m.exists():
        return None
    try:
        return json.loads(m.read_text(encoding="utf-8")).get("id")
    except Exception:
        return None

def write_marker(folder: Path, pid: str):
    (folder / MARKER).write_text(
        json.dumps({"id": pid, "nombre": folder.name}, ensure_ascii=False, indent=2),
        encoding="utf-8")

def has_claude_data(key: str) -> bool:
    d = PROJECTS / key
    return d.is_dir()

def has_memory(key: str) -> bool:
    mem = PROJECTS / key / "memory"
    return mem.is_dir() and any(mem.glob("*.md"))


def relink(old_key: str, new_key: str, old_path: str = "", new_path: str = "") -> int:
    """Copia memoria + transcripts de la clave vieja a la nueva (sin pisar lo más nuevo)
    y archiva la clave vieja. En los .jsonl reescribe la ruta vieja por la nueva (el
    campo 'cwd' y cualquier ruta absoluta embebida quedan hardcodeados adentro, así que
    hay que reescribirlos para poder reanudar sesiones con --resume). Devuelve cuántos
    archivos de memoria copió."""
    old_dir, new_dir = PROJECTS / old_key, PROJECTS / new_key
    if not old_dir.is_dir():
        return 0
    (new_dir / "memory").mkdir(parents=True, exist_ok=True)
    copied = 0
    om = old_dir / "memory"
    if om.is_dir():
        for f in om.iterdir():
            dst = new_dir / "memory" / f.name
            if f.is_file() and not dst.exists():
                shutil.copy2(f, dst); copied += 1
    for f in old_dir.glob("*.jsonl"):
        dst = new_dir / f.name
        if not dst.exists():
            if old_path and new_path:
                # reescribir la ruta vieja embebida (cwd, etc.) por la nueva
                dst.write_text(f.read_text(encoding="utf-8").replace(old_path, new_path),
                               encoding="utf-8")
            else:
                shutil.copy2(f, dst)
    ARCHIVE.mkdir(parents=True, exist_ok=True)
    shutil.move(str(old_dir), str(ARCHIVE / f"{old_key}.{int(time.time())}"))
    return copied


def sync_claude_json(reg: dict):
    """Migra la config por-proyecto de ~/.claude.json (MCP, permisos, confianza) desde
    rutas viejas (huérfanas) a la ruta ACTUAL de cada proyecto. NO destructivo: solo
    rellena claves que falten en el destino, nunca pisa lo que ya está. Devuelve la
    lista de (ruta_vieja, ruta_nueva, claves_migradas)."""
    if not CLAUDE_JSON.exists():
        return []
    data = json.loads(CLAUDE_JSON.read_text(encoding="utf-8"))
    projects = data.get("projects", {})
    if not isinstance(projects, dict):
        return []
    # índice: basename -> ruta(s) actuales de proyectos que existen en disco
    cur = {}
    for e in reg["projects"].values():
        p = e["path"]
        if os.path.isdir(p):
            cur.setdefault(os.path.basename(p), []).append(p)
    changes = []
    for opath, ocfg in list(projects.items()):
        if os.path.isdir(opath) or not isinstance(ocfg, dict):
            continue  # solo entradas huérfanas (carpeta ya no existe)
        payload = {k: ocfg[k] for k in CONFIG_KEYS if ocfg.get(k)}
        if not payload:
            continue  # nada que valga la pena migrar
        targets = cur.get(os.path.basename(opath), [])
        if len(targets) != 1:
            continue  # sin match único → no arriesgar
        dest = projects.setdefault(targets[0], {})
        added = [k for k, v in payload.items() if not dest.get(k) and dest.__setitem__(k, v) is None]
        if added:
            changes.append((opath, targets[0], added))
    if changes:
        shutil.copy2(CLAUDE_JSON, CLAUDE_JSON.with_suffix(f".json.bak-{int(time.time())}"))
        CLAUDE_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    return changes


def prune_claude_json(reg: dict):
    """Elimina de ~/.claude.json las entradas huérfanas (carpeta inexistente) que son
    seguras de borrar: las que no tienen config, o las que SÍ tienen pero ya fue migrada
    a un proyecto vivo con el mismo basename. Nunca borra config sin respaldo. Backup
    previo. Devuelve la lista de rutas eliminadas."""
    if not CLAUDE_JSON.exists():
        return []
    data = json.loads(CLAUDE_JSON.read_text(encoding="utf-8"))
    projects = data.get("projects", {})
    if not isinstance(projects, dict):
        return []
    live = {}  # basename -> config del proyecto vivo (para chequear si ya se migró)
    for p, c in projects.items():
        if os.path.isdir(p) and isinstance(c, dict):
            live.setdefault(os.path.basename(p), c)
    removed = []
    for p in list(projects.keys()):
        c = projects[p]
        if os.path.isdir(p):
            continue  # existe → no tocar
        payload = {k for k in CONFIG_KEYS if isinstance(c, dict) and c.get(k)}
        if not payload:
            del projects[p]; removed.append(p); continue      # nada que perder
        tgt = live.get(os.path.basename(p))                    # ¿ya migrada a un vivo?
        if tgt is not None and all(tgt.get(k) for k in payload):
            del projects[p]; removed.append(p)
    if removed:
        shutil.copy2(CLAUDE_JSON, CLAUDE_JSON.with_suffix(f".json.bak-{int(time.time())}"))
        CLAUDE_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    return removed


def process_folder(path: str, reg: dict, force_mint: bool):
    """Procesa UNA carpeta: enrola si falta DNI y reconecta si se movió.
    force_mint=True (hook): estampa DNI aunque todavía no haya data de Claude.
    Devuelve (accion, detalle) o None si no aplica."""
    path = os.path.abspath(path).rstrip("/")
    folder = Path(path)
    if not folder.is_dir():
        return None
    key = enc(path)
    pid = read_marker(folder)

    if pid is None:
        if not force_mint and not has_claude_data(key):
            return None  # no es un proyecto de Claude: no tocar
        pid = uuid.uuid4().hex[:8]
        write_marker(folder, pid)

    entry = reg["projects"].get(pid)
    if entry is None:
        reg["projects"][pid] = {
            "nombre": folder.name, "path": path, "key": key,
            "historial": [f"{now()} · enrolado en {path}"],
        }
        return ("enrolado", path)

    if entry["key"] != key:                      # se movió o renombró
        n = relink(entry["key"], key, entry["path"], path)
        entry["historial"].append(f"{now()} · {entry['path']} → {path}")
        entry["nombre"], entry["path"], entry["key"] = folder.name, path, key
        return ("movido", f"{path}  ({n} memorias reconectadas)")

    if entry["path"] != path:                    # ruta cosmética distinta
        entry["path"] = path
    return ("ok", path)


def iter_project_folders(roots):
    """Recorre las raíces (acotado) y devuelve carpetas candidatas a proyecto:
    las que tienen DNI o las que tienen data de Claude para su clave."""
    seen = set()
    for root in roots:
        root = os.path.abspath(os.path.expanduser(root))
        if not os.path.isdir(root):
            continue
        base_depth = root.rstrip("/").count(os.sep)
        for dirpath, dirnames, filenames in os.walk(root):
            depth = dirpath.rstrip("/").count(os.sep) - base_depth
            dirnames[:] = [] if depth >= MAX_DEPTH else [d for d in dirnames
                           if d not in PRUNE and not d.startswith(".")]
            if dirpath == root:
                continue
            has_dni = MARKER in filenames
            is_proj = has_claude_data(enc(os.path.abspath(dirpath)))
            if (has_dni or is_proj) and dirpath not in seen:
                seen.add(dirpath)
                yield dirpath
                # seguimos descendiendo: puede haber proyectos ANIDADOS
                # (ej. ~/Projects/course/final-project dentro de ~/Projects/course). PRUNE + MAX_DEPTH acotan el costo.


def cmd_enroll(path):
    reg = load_registry()
    res = process_folder(path, reg, force_mint=True)
    save_registry(reg)
    if res and res[0] in ("enrolado", "movido"):
        print(f"[claude-dni] {res[0]}: {res[1]}")

def cmd_reconcile():
    reg = load_registry()
    roots = reg["_config"].get("roots", DEFAULT_ROOTS)
    counts = {"enrolado": 0, "movido": 0, "ok": 0}
    moved, enrolled = [], []
    for folder in iter_project_folders(roots):
        res = process_folder(folder, reg, force_mint=False)
        if not res:
            continue
        counts[res[0]] = counts.get(res[0], 0) + 1
        if res[0] == "movido":   moved.append(res[1])
        if res[0] == "enrolado": enrolled.append(res[1])
    save_registry(reg)
    cfg_changes = sync_claude_json(reg)
    print(f"Raíces vigiladas: {', '.join(roots)}")
    print(f"Proyectos: {counts['ok']} sin cambios · {counts['enrolado']} enrolados · {counts['movido']} movidos\n")
    if enrolled:
        print("Enrolados (DNI nuevo):")
        for e in enrolled: print(f"  + {e}")
    if moved:
        print("Reconectados (se movieron):")
        for m in moved: print(f"  ↪ {m}")
    if cfg_changes:
        print("Config de ~/.claude.json migrada (MCP/permisos/confianza):")
        for old, new, keys in cfg_changes:
            print(f"  ⚙ {new}  ←  [{', '.join(keys)}]")
    if not enrolled and not moved and not cfg_changes:
        print("Todo ya estaba en orden. Nada que reconectar.")

def cmd_status():
    reg = load_registry()
    print(f"Registro: {REGISTRY}")
    print(f"Raíces: {', '.join(reg['_config'].get('roots', []))}")
    print(f"Proyectos registrados: {len(reg['projects'])}\n")
    for pid, e in sorted(reg["projects"].items(), key=lambda x: x[1]["nombre"].lower()):
        print(f"  [{pid}] {e['nombre']}")
        print(f"        {e['path']}")
        if len(e.get("historial", [])) > 1:
            print(f"        movimientos: {len(e['historial'])-1}")


if __name__ == "__main__":
    args = sys.argv[1:]
    if not args:
        print(__doc__); sys.exit(1)
    cmd = args[0]
    if cmd == "enroll" and len(args) >= 2:
        cmd_enroll(args[1])
    elif cmd == "reconcile":
        cmd_reconcile()
    elif cmd == "sync-config":
        reg = load_registry()
        changes = sync_claude_json(reg)
        if changes:
            print("Config de ~/.claude.json migrada:")
            for old, new, keys in changes:
                print(f"  ⚙ {new}  ←  [{', '.join(keys)}]  (venía de {old})")
        else:
            print("Nada que migrar en ~/.claude.json.")
    elif cmd == "prune-config":
        reg = load_registry()
        removed = prune_claude_json(reg)
        if removed:
            print(f"Entradas huérfanas eliminadas de ~/.claude.json: {len(removed)}")
            for p in removed: print(f"  − {p}")
        else:
            print("No hay entradas huérfanas seguras de eliminar.")
    elif cmd == "status":
        cmd_status()
    else:
        print(__doc__); sys.exit(1)
bash
mkdir -p ~/.claude/scripts
mv ~/Downloads/claude_projects.py ~/.claude/scripts/
chmod +x ~/.claude/scripts/claude_projects.py

02Point it at your folders

Edit the watched roots near the top of the script — the parent folders your projects live under. Once the registry exists you can edit _config.roots there instead.

python
# claude_projects.py — near the top
DEFAULT_ROOTS = [str(HOME / "Developer"), str(HOME / "Projects")]

03Add the auto-enrol hook

Inside "hooks" in ~/.claude/settings.json. This is what stamps the id on every project you open.

~/.claude/settings.json
"SessionStart": [
  { "hooks": [ { "type": "command",
    "command": "python3 \"$HOME/.claude/scripts/claude_projects.py\" enroll \"${CLAUDE_PROJECT_DIR:-$PWD}\" >/dev/null 2>&1 || true" } ] }
]

04Enrol what you already have

One pass over the watched roots. Every existing project gets an id and a place in the registry.

bash
python3 ~/.claude/scripts/claude_projects.py reconcile

Commands

Day to day there is only one: rearrange folders however you like, then run reconcile and read the summary of what moved.

bash
# Reconnect everything that moved + enrol anything new. The one you'll actually use.
python3 ~/.claude/scripts/claude_projects.py reconcile

# Show the registry: which project lives where, and how many times it moved
python3 ~/.claude/scripts/claude_projects.py status

# Enrol / reconnect a single folder (this is what the hook runs)
python3 ~/.claude/scripts/claude_projects.py enroll "/path/to/project"

# Migrate only the ~/.claude.json side: MCP servers, permissions, trust.
# Non-destructive. Takes effect after restarting Claude Code.
python3 ~/.claude/scripts/claude_projects.py sync-config

# Drop orphaned ~/.claude.json entries that are already migrated or hold no config
python3 ~/.claude/scripts/claude_projects.py prune-config

The macOS encoding trap

This is the part that costs an afternoon if you write it yourself. macOS stores filenames in Unicode NFD (decomposed — the í is an i plus a combining accent). Claude Code normalises the path to NFC first and then replaces every non-ASCII-alphanumeric character with a dash — accents included. So Estadística becomes Estad-stica.

Which means you cannot use Python's str.isalnum(): it counts accented characters as alphanumeric and hands you a key that doesn't exist. Normalise to NFC, then restrict to ASCII.

the only way to compute the key
import unicodedata

def enc(path):
    path = unicodedata.normalize("NFC", path)
    return "".join(c if (c.isascii() and c.isalnum()) else "-" for c in path)

Safety

If you reconnect, open Claude in the folder and it still doesn't remember, the real key differs from the computed one: look at the name that appeared under ~/.claude/projects/ and adjust.


Optional pieces

The skill

Drop it in ~/.claude/skills/reordenar-memorias/SKILL.md and you can just tell Claude "I moved folders, reorganise the memories" instead of remembering the command.

SKILL.mdDownload ↓
---
name: reordenar-memorias
description: Reconecta las memorias de Claude Code cuando el usuario mueve, renombra o reorganiza carpetas de proyecto. Usar cuando diga cosas como "moví carpetas", "reordená las memorias", "reorganicé proyectos", "cambié una carpeta de lugar y se desconectó la memoria", o cuando cree/quiera registrar un proyecto nuevo en el sistema de DNI. También para revisar el estado del registro o agregar una carpeta raíz a vigilar.
---

# Reordenar memorias de proyectos (sistema de DNI)

Claude Code indexa memoria e historial por la **ruta absoluta** de la carpeta
(`~/.claude/projects/<clave>/`), así que mover/renombrar una carpeta **desconecta** su memoria.
Este sistema lo resuelve dándole a cada proyecto un DNI estable que viaja dentro de la carpeta
(`.claude-project-id`) y un registro central que recuerda dónde estaba cada uno.

## Herramienta
Motor: `~/.claude/scripts/claude_projects.py` (Python 3, sin dependencias).
Registro: `~/.claude/project-registry.json`. Backups: `~/.claude/projects-archive/`.

## Qué hacer según lo que pida el usuario

**"Moví carpetas / reordená / reorganicé"** → correr:
```bash
python3 ~/.claude/scripts/claude_projects.py reconcile
```
Escanea las raíces vigiladas, enrola proyectos nuevos y reconecta los movidos. Reconecta
memoria + transcripts (reescribiendo el `cwd` interno de los `.jsonl`) y además migra la
config por-proyecto de `~/.claude.json` (servidores MCP, permisos, confianza). Después
mostrar al usuario el resumen tal cual lo imprime el script. Los cambios de `~/.claude.json`
**toman efecto al reiniciar Claude Code**.

**"Se desconectó un conector/MCP/permiso al mover"** → es config de `~/.claude.json`. Correr:
```bash
python3 ~/.claude/scripts/claude_projects.py sync-config     # migra MCP/permisos/confianza a la ruta nueva (no destructivo)
python3 ~/.claude/scripts/claude_projects.py prune-config    # limpia entradas huérfanas ya migradas o sin config
```

**"Mostrame el estado / qué proyectos hay"** → correr:
```bash
python3 ~/.claude/scripts/claude_projects.py status
```

**"Registrá este proyecto / enrolá esta carpeta"** (una sola) →
```bash
python3 ~/.claude/scripts/claude_projects.py enroll "/ruta/al/proyecto"
```

**"Sumá esta carpeta raíz a vigilar"** → editar `_config.roots` en
`~/.claude/project-registry.json` (agregar la ruta absoluta) y después correr `reconcile`.

## Reglas y detalles importantes
- **Encoding de clave (macOS):** el script normaliza a NFC y reemplaza todo lo no-ASCII-alfanumérico por `-` (los acentos incluidos: `Estadística` → `Estad-stica`). Esto ya está resuelto dentro del script; no recalcular claves a mano con otra lógica.
- **El hook `SessionStart`** ya estampa el DNI automáticamente al abrir cualquier proyecto, así que los proyectos nuevos se enrolan solos. `reconcile` es para reconectar movimientos en lote.
- **Nunca borrar** claves de `~/.claude/projects/` a mano; el script archiva en `projects-archive/`.
- Raíces vigiladas: las que estén en `_config.roots` del registro (por defecto `~/Developer`, `~/Projects`; editables).
- Si después de reconectar el usuario abre Claude en la carpeta y no recuerda, la clave real difiere: mirar el nombre creado en `~/.claude/projects/` y ajustar.
- Documentación de fondo: memoria `reference-sistema-dni-proyectos`.

The single-project mover

For when you want to move one project by naming the old and new paths explicitly, without scanning anything. Moves the folder and reconnects the memory in one shot.

claude-mv-project.shDownload ↓
#!/usr/bin/env bash
#
# claude-mv-project.sh — mueve un proyecto y RECONECTA su memoria/historial de Claude Code.
#
# Claude Code indexa memoria e historial por la RUTA ABSOLUTA de la carpeta
# (~/.claude/projects/<clave>/), así que al mover/renombrar la carpeta se
# "desconecta". Este script recoloca esa data a la clave de la ruta nueva.
#
# USO:
#   claude-mv-project.sh "<ruta_vieja>" "<ruta_nueva>"            # (dry-run: solo muestra qué haría)
#   claude-mv-project.sh "<ruta_vieja>" "<ruta_nueva>" --apply    # ejecuta
#   claude-mv-project.sh "<ruta_vieja>" "<ruta_nueva>" --apply --move-files
#         ^ además MUEVE la carpeta en disco (si todavía no la moviste)
#
# EJEMPLOS:
#   claude-mv-project.sh ~/Downloads/mi-app ~/Developer/mi-app --apply --move-files
#   claude-mv-project.sh "~/Projects/old-name" "~/Projects/new-name" --apply
#
set -euo pipefail

OLD_RAW="${1:-}"; NEW_RAW="${2:-}"
APPLY=0; MOVEFILES=0
for a in "${@:3}"; do
  case "$a" in
    --apply) APPLY=1 ;;
    --move-files) MOVEFILES=1 ;;
    *) echo "flag desconocido: $a"; exit 2 ;;
  esac
done
if [ -z "$OLD_RAW" ] || [ -z "$NEW_RAW" ]; then
  grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 1
fi

# Expandir ~ y normalizar a ruta absoluta (sin exigir que exista todavía).
expand() { eval echo "$1"; }
OLD="$(expand "$OLD_RAW")"; NEW="$(expand "$NEW_RAW")"

# Codificación de ruta -> clave, igual que Claude Code:
# cada carácter que NO es letra/dígito (Unicode: acentos cuentan como letra) -> '-'
enc() { python3 -c "import sys; p=sys.argv[1]; print(''.join(c if c.isalnum() else '-' for c in p))" "$1"; }

PROJ="$HOME/.claude/projects"
OLD_KEY="$(enc "$OLD")"; NEW_KEY="$(enc "$NEW")"
OLD_DIR="$PROJ/$OLD_KEY"; NEW_DIR="$PROJ/$NEW_KEY"

echo "──────────────────────────────────────────────────────────"
echo "Ruta vieja : $OLD"
echo "Ruta nueva : $NEW"
echo "Clave vieja: $OLD_KEY"
echo "Clave nueva: $NEW_KEY"
echo "──────────────────────────────────────────────────────────"

# ¿Existe la data vieja? Si no, buscar candidatos parecidos.
if [ ! -d "$OLD_DIR" ]; then
  echo "⚠ No encontré data de Claude en la clave vieja calculada."
  base="$(basename "$OLD")"; hint="$(enc "$base")"
  echo "  Candidatos que contienen '$hint':"
  ls -1 "$PROJ" | grep -F "$hint" | sed 's/^/    /' || echo "    (ninguno)"
  echo "  → Si ves la clave correcta arriba, corré el script con esa ruta exacta,"
  echo "    o pedile a Claude que la reconecte."
  exit 1
fi

nmem=$(ls -1 "$OLD_DIR/memory"/*.md 2>/dev/null | wc -l | tr -d ' ')
nses=$(ls -1 "$OLD_DIR"/*.jsonl 2>/dev/null | wc -l | tr -d ' ')
echo "Data a reconectar: ${nmem} archivos de memoria, ${nses} transcripts de sesión."

if [ "$APPLY" -eq 0 ]; then
  echo ""
  echo "DRY-RUN (no cambié nada). Para ejecutar, agregá  --apply"
  [ "$MOVEFILES" -eq 1 ] && echo "(y con --move-files además movería la carpeta en disco)"
  exit 0
fi

# Mover la carpeta en disco (opcional)
if [ "$MOVEFILES" -eq 1 ]; then
  if [ ! -d "$OLD" ]; then echo "✗ La carpeta en disco no existe: $OLD"; exit 1; fi
  mkdir -p "$(dirname "$NEW")"
  mv "$OLD" "$NEW"
  echo "✓ Carpeta movida en disco: $NEW"
fi

# Reconectar la data de Claude (merge si el destino ya existe)
mkdir -p "$NEW_DIR/memory"
[ -d "$OLD_DIR/memory" ] && cp -R "$OLD_DIR/memory/." "$NEW_DIR/memory/" 2>/dev/null || true
for j in "$OLD_DIR"/*.jsonl; do [ -e "$j" ] && cp -n "$j" "$NEW_DIR/" || true; done
echo "✓ Memoria e historial copiados a la clave nueva."

# Dejar la clave vieja como respaldo renombrada (no la borro por las dudas)
mv "$OLD_DIR" "${OLD_DIR}__movido-$(python3 -c 'import time;print(int(time.time()))')" 2>/dev/null || true
echo "✓ Clave vieja archivada como respaldo (borrala cuando confirmes que anda)."
echo ""
echo "VERIFICACIÓN: abrí Claude Code en  $NEW  y preguntale algo del proyecto."
echo "Si recuerda → listo. Si no → el nombre real de la clave difiere; mirá en"
echo "  ~/.claude/projects/  la carpeta que se creó al abrir, y avisá para ajustar."

What the registry ends up looking like

Written and maintained for you — this is only here so you know what you're looking at if you open it.

project-registry.example.jsonDownload ↓
{
  "_config": {
    "roots": [
      "/Users/youruser/Developer",
      "/Users/youruser/Projects"
    ],
    "version": 1
  },
  "projects": {
    "a1b2c3d4": {
      "nombre": "my-web-app",
      "path": "/Users/youruser/Developer/my-web-app",
      "key": "-Users-youruser-Developer-my-web-app",
      "historial": [
        "2025-01-15 10:00 · enrolado en /Users/youruser/Developer/my-web-app"
      ]
    },
    "e5f6a7b8": {
      "nombre": "university-notes",
      "path": "/Users/youruser/Projects/2nd-year/university-notes",
      "key": "-Users-youruser-Projects-2nd-year-university-notes",
      "historial": [
        "2025-01-15 10:00 · enrolado en /Users/youruser/Downloads/university-notes",
        "2025-02-01 09:30 · /Users/youruser/Downloads/university-notes → /Users/youruser/Projects/2nd-year/university-notes"
      ]
    }
  }
}

All files