{"slug": "move-claude-cli-projects-macos", "title": "Move Claude CLI Projects - MacOS", "summary": "A developer has released a shell script, mvclaude, that moves a project directory along with its Claude Code CLI context, including session transcripts, project settings, and history. The script handles the encoding of absolute paths into Claude's storage format and offers options to clean or merge existing context at the destination.", "body_md": "|\n#!/bin/bash |\n|\nset -e |\n|\n|\n|\n# mvclaude — move a project directory AND its Claude Code (CLI) context with it. |\n|\n# |\n|\n# Usage: mvclaude <old_directory> <new_directory> |\n|\n# |\n|\n# What Claude Code keys off the project path: |\n|\n# ~/.claude/projects/<encoded-path>/ session transcripts (*.jsonl) |\n|\n# ~/.claude.json -> \"projects\" object keyed by the ABSOLUTE path |\n|\n# (trust, allowed tools, per-project MCP servers) |\n|\n# ~/.claude/history.jsonl global prompt history (contains cwd) |\n|\n# |\n|\n# Encoding rule (verified in claude v2.1.251): |\n|\n# every non-alphanumeric character becomes \"-\" [^a-zA-Z0-9] -> - |\n|\n|\n|\nif [ $# -ne 2 ]; then |\n|\necho \"Usage: mvclaude <old_directory> <new_directory>\" |\n|\necho \"Example: mvclaude ~/old-project ~/new-project\" |\n|\nexit 1 |\n|\nfi |\n|\n|\n|\nOLD_DIR=\"$1\" |\n|\nNEW_DIR=\"$2\" |\n|\nCLAUDE_DIR=\"$HOME/.claude\" |\n|\n|\n|\n# --- Validate everything BEFORE touching anything ----------------------------- |\n|\n|\n|\nOLD_ABS=$(cd \"$OLD_DIR\" 2>/dev/null && pwd || true) |\n|\nif [ -z \"$OLD_ABS\" ]; then |\n|\necho \"Error: old directory does not exist: $OLD_DIR\" |\n|\nexit 1 |\n|\nfi |\n|\n|\n|\nif [ -e \"$NEW_DIR\" ]; then |\n|\necho \"Error: destination already exists: $NEW_DIR\" |\n|\nexit 1 |\n|\nfi |\n|\n|\n|\ncase \"$NEW_DIR\" in |\n|\n/*) NEW_PARENT=$(dirname \"$NEW_DIR\") ;; |\n|\n*) NEW_PARENT=$(dirname \"./$NEW_DIR\") ;; |\n|\nesac |\n|\nNEW_PARENT_ABS=$(cd \"$NEW_PARENT\" 2>/dev/null && pwd || true) |\n|\nif [ -z \"$NEW_PARENT_ABS\" ]; then |\n|\necho \"Error: parent of destination does not exist: $NEW_PARENT\" |\n|\nexit 1 |\n|\nfi |\n|\nNEW_ABS=\"$NEW_PARENT_ABS/$(basename \"$NEW_DIR\")\" |\n|\n|\n|\n# Correct encoding: ALL non-alphanumerics -> \"-\" (not just \"/\" and \".\") |\n|\nencode() { printf '%s' \"$1\" | sed 's/[^a-zA-Z0-9]/-/g'; } |\n|\nOLD_ENCODED=$(encode \"$OLD_ABS\") |\n|\nNEW_ENCODED=$(encode \"$NEW_ABS\") |\n|\n|\n|\n# Claude truncates encodings > 200 chars and appends a hash; not handled here. |\n|\nif [ ${#OLD_ENCODED} -gt 200 ] || [ ${#NEW_ENCODED} -gt 200 ]; then |\n|\necho \"Error: encoded path exceeds 200 chars; Claude hash-truncates these. Aborting.\" |\n|\nexit 1 |\n|\nfi |\n|\n|\n|\n# --- Check for existing context at the destination ---------------------------- |\n|\n|\n|\nSUBDIRS=\"projects file-history todos shell-snapshots debug\" |\n|\n|\n|\nEXISTING_CONTEXT=\"\" |\n|\nfor subdir in $SUBDIRS; do |\n|\nNEW_PATH=\"$CLAUDE_DIR/$subdir/$NEW_ENCODED\" |\n|\nif [ -e \"$NEW_PATH\" ]; then |\n|\nEXISTING_CONTEXT=\"$EXISTING_CONTEXT $subdir\" |\n|\nfi |\n|\ndone |\n|\n|\n|\nif [ -n \"$EXISTING_CONTEXT\" ]; then |\n|\necho \"Warning: Claude context already exists for $NEW_ABS:\" |\n|\nfor item in $EXISTING_CONTEXT; do |\n|\nCOUNT=\"\" |\n|\nif [ \"$item\" = \"projects\" ] && [ -d \"$CLAUDE_DIR/$item/$NEW_ENCODED\" ]; then |\n|\nSESSION_COUNT=$(find \"$CLAUDE_DIR/$item/$NEW_ENCODED\" -maxdepth 1 -name \"*.jsonl\" 2>/dev/null | wc -l | tr -d ' ') |\n|\nCOUNT=\" ($SESSION_COUNT sessions)\" |\n|\nfi |\n|\necho \" - $item$COUNT\" |\n|\ndone |\n|\necho \"\" |\n|\necho \"Options:\" |\n|\necho \" [c] Clean out existing context and continue\" |\n|\necho \" [m] Merge old context into existing context\" |\n|\necho \" [n] Abort (default)\" |\n|\necho \"\" |\n|\nread -p \"Choose [c/m/N]: \" -n 1 -r |\n|\necho |\n|\nif [[ $REPLY =~ ^[Cc]$ ]]; then |\n|\nfor item in $EXISTING_CONTEXT; do |\n|\nrm -rf \"$CLAUDE_DIR/$item/$NEW_ENCODED\" |\n|\necho \" removed $item\" |\n|\ndone |\n|\nelif [[ $REPLY =~ ^[Mm]$ ]]; then |\n|\necho \"Will merge contexts...\" |\n|\nelse |\n|\necho \"Aborted.\" |\n|\nexit 1 |\n|\nfi |\n|\necho \"\" |\n|\nfi |\n|\n|\n|\n# --- 1. Move the real directory FIRST (most likely thing to fail) ------------- |\n|\n|\n|\necho \"Moving directory:\" |\n|\necho \" $OLD_ABS\" |\n|\necho \" -> $NEW_ABS\" |\n|\nmv \"$OLD_ABS\" \"$NEW_ABS\" |\n|\necho \"\" |\n|\n|\n|\n# --- 2. Move / merge the context directories ---------------------------------- |\n|\n|\n|\nMOVED=0 |\n|\nfor subdir in $SUBDIRS; do |\n|\nOLD_PATH=\"$CLAUDE_DIR/$subdir/$OLD_ENCODED\" |\n|\nNEW_PATH=\"$CLAUDE_DIR/$subdir/$NEW_ENCODED\" |\n|\nif [ -d \"$OLD_PATH\" ]; then |\n|\nif [ -d \"$NEW_PATH\" ]; then |\n|\necho \"Merging $subdir/$OLD_ENCODED -> $subdir/$NEW_ENCODED\" |\n|\n# find, not glob: also moves dotfiles |\n|\nfind \"$OLD_PATH\" -mindepth 1 -maxdepth 1 -exec mv {} \"$NEW_PATH/\" \\; |\n|\nrmdir \"$OLD_PATH\" 2>/dev/null || rm -rf \"$OLD_PATH\" |\n|\nelse |\n|\necho \"Moving $subdir/$OLD_ENCODED -> $subdir/$NEW_ENCODED\" |\n|\nmv \"$OLD_PATH\" \"$NEW_PATH\" |\n|\nfi |\n|\nMOVED=$((MOVED + 1)) |\n|\nelif [ -f \"$OLD_PATH\" ]; then |\n|\necho \"Moving $subdir/$OLD_ENCODED -> $subdir/$NEW_ENCODED\" |\n|\nmv \"$OLD_PATH\" \"$NEW_PATH\" |\n|\nMOVED=$((MOVED + 1)) |\n|\nfi |\n|\ndone |\n|\n|\n|\nif [ \"$MOVED\" -eq 0 ]; then |\n|\necho \"No Claude context found for $OLD_ABS\" |\n|\nelse |\n|\necho \"Moved $MOVED context location(s)\" |\n|\nfi |\n|\necho \"\" |\n|\n|\n|\n# --- 3. Rewrite path references (python: safe with any characters) ------------ |\n|\n# Replaces OLD_ABS only when NOT followed by [A-Za-z0-9_.-], so moving |\n|\n# /a/b never rewrites /a/b2, /a/b_x, or /a/b.bak ( /a/b/sub still matches ). |\n|\n|\n|\necho \"Updating path references...\" |\n|\npython3 - \"$OLD_ABS\" \"$NEW_ABS\" \"$CLAUDE_DIR\" \"$NEW_ENCODED\" <<'PYEOF' |\n|\nimport json, re, shutil, sys, time |\n|\nfrom pathlib import Path |\n|\n|\n|\nold, new, claude_dir, new_encoded = sys.argv[1:5] |\n|\nclaude_dir = Path(claude_dir) |\n|\nstamp = time.strftime(\"%Y%m%d-%H%M%S\") |\n|\npat = re.compile(re.escape(old) + r'(?![A-Za-z0-9_.-])') |\n|\n|\n|\ndef rewrite(p): |\n|\ns = p.read_text(errors=\"surrogateescape\") |\n|\ns2 = pat.sub(lambda m: new, s) |\n|\nif s2 != s: |\n|\np.write_text(s2, errors=\"surrogateescape\") |\n|\nreturn 1 |\n|\nreturn 0 |\n|\n|\n|\n# Session transcripts |\n|\nproj = claude_dir / \"projects\" / new_encoded |\n|\nn = sum(rewrite(f) for f in proj.rglob(\"*.jsonl\")) if proj.is_dir() else 0 |\n|\nprint(f\" session files updated: {n}\") |\n|\n|\n|\n# Global history |\n|\nhist = claude_dir / \"history.jsonl\" |\n|\nif hist.is_file(): |\n|\nshutil.copy2(hist, hist.with_name(f\"history.jsonl.bak-{stamp}\")) |\n|\nif rewrite(hist): |\n|\nprint(\" history.jsonl updated (backup kept)\") |\n|\n|\n|\n# ~/.claude.json: per-project trust / allowed tools / MCP servers |\n|\ncfg = Path.home() / \".claude.json\" |\n|\nif cfg.is_file(): |\n|\ndata = json.loads(cfg.read_text()) |\n|\nprojects = data.get(\"projects\", {}) |\n|\nif old in projects: |\n|\nshutil.copy2(cfg, cfg.with_name(f\".claude.json.bak-{stamp}\")) |\n|\nentry = projects.pop(old) |\n|\nif new in projects: |\n|\nentry.update(projects[new]) # existing destination entry wins |\n|\nprojects[new] = entry |\n|\ncfg.write_text(json.dumps(data, indent=2)) |\n|\nprint(\" ~/.claude.json project entry moved (backup kept)\") |\n|\nPYEOF |\n|\n|\n|\necho \"\" |\n|\necho \"Done: $OLD_ABS -> $NEW_ABS\" |", "url": "https://wpnews.pro/news/move-claude-cli-projects-macos", "canonical_source": "https://gist.github.com/rjmoggach/4442ebd577f19e67c289cfb159e12c17", "published_at": "2026-08-31 18:33:47+00:00", "updated_at": "2026-08-31 18:52:36.233607+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/move-claude-cli-projects-macos", "markdown": "https://wpnews.pro/news/move-claude-cli-projects-macos.md", "text": "https://wpnews.pro/news/move-claude-cli-projects-macos.txt", "jsonld": "https://wpnews.pro/news/move-claude-cli-projects-macos.jsonld"}}