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 memory and the transcripts
Both live under ~/.claude/projects/<key>/ — and the key is the path. New path, new key, empty project.
The cwd baked into each session file
Every .jsonl transcript hardcodes the working directory it was recorded in, so moving the folder alone isn't enough — the files have to be rewritten.
The per-project config in ~/.claude.json
MCP servers, allowed tools and the trust flag are stored against the old path. This is why a connector silently disappears after a move.
The key is just the path with every non-alphanumeric character turned into a dash:
| Folder on disk | Key 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:
- 01
Scan
Walk the watched roots looking for .claude-project-id files.
- 02
Compare
For each id, check where it is now against where the registry said it was.
- 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.
#!/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)
mkdir -p ~/.claude/scripts
mv ~/Downloads/claude_projects.py ~/.claude/scripts/
chmod +x ~/.claude/scripts/claude_projects.py02Point 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.
# 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.
"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.
python3 ~/.claude/scripts/claude_projects.py reconcileCommands
Day to day there is only one: rearrange folders however you like, then run reconcile and read the summary of what moved.
# 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-configThe 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.
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
- ·It never deletes memory. Reconnecting archives the old key under ~/.claude/projects-archive/, timestamped.
- ·Copying never overwrites a file that already exists at the destination.
- ·Editing ~/.claude.json is additive — config is only filled in where missing — and the file is backed up before any write.
- ·prune-config only drops an orphaned entry once its config already exists at the live path, or when the entry holds no config at all.
- ·Nested projects are supported: a project inside another project keeps its own memory.
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.
---
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.
#!/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.
{
"_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
- Download ↓
claude_projects.py
The engine: enroll / reconcile / status / sync-config / prune-config
- Download ↓
claude-mv-project.sh
Standalone single-project mover
- Download ↓
SKILL.md
Optional Claude Code skill wrapper
- Download ↓
project-registry.example.json
What the registry looks like once populated
- Download ↓
LICENSE
MIT