# Claude Code wrapper with session naming and auto-summarization

> Source: <https://gist.github.com/renaudjx/2b5852276eaee16b00aaa4746810dbb9>
> Published: 2026-05-16 15:13:56+00:00

| #!/usr/bin/env bash | |
| # cc - Claude Code wrapper with session naming | |
| # Adapted from https://gist.github.com/zinknovo/cf21268e90419e9c0e93a9bd448591a4 | |
| # | |
| # Local changes: | |
| # - Config dir defaults to ~/.claude-2 (override via $CLAUDE_CONFIG_DIR) | |
| # - Destructive ops use `trash` instead of `rm` (per CLAUDE.md global rules) | |
| # - `clear` requires typing "yes" + shows session count | |
| # - Gemini auto-summary is opt-in via CC_AUTO_SUMMARY=1 | |
| # - Removed _check_version (depended on external check-cli-compat) | |
| # | |
| # Usage: | |
| # cc Start a new Claude Code session | |
| # cc n <name> Start a new session with a name | |
| # cc ls List sessions (current project) | |
| # cc lsa List sessions across all projects | |
| # cc r <name|index> Resume a session by name or index | |
| # cc rn <index> <name> Rename a session | |
| # cc rm <name|index> Delete (trash) a session | |
| # cc clear Delete (trash) all sessions | |
| # | |
| # Env: | |
| # CLAUDE_CONFIG_DIR Default: $HOME/.claude-2 | |
| # CC_AUTO_SUMMARY=1 Enable Gemini-based prev-session auto-summary on `ls` | |
| # CLAUDE_CMD Underlying CLI command (default: claude) | |
| # CMD_NAME Display name in help text (default: basename $0) | |
| set -euo pipefail | |
| CLAUDE_CMD="${CLAUDE_CMD:-$HOME/.local/bin/claude}" | |
| CMD_NAME="${CMD_NAME:-$(basename "$0")}" | |
| CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude-2}" | |
| export CLAUDE_CONFIG_DIR="$CLAUDE_DIR" | |
| # Mirror the user's `claude` alias: write a profile marker so other tools | |
| # can detect which profile (claude-1 / claude-2) is active in this tty. | |
| _write_profile_marker() { | |
| local profile="${CLAUDE_DIR##*/.}" # ~/.claude-2 -> claude-2 | |
| local tty_id | |
| tty_id=$({ tty 2>/dev/null || true; } | tr '/' '_') | |
| [ -n "$tty_id" ] && [ "$tty_id" != "not a tty" ] && echo "$profile" > "/tmp/.claude_profile_$tty_id" 2>/dev/null || true | |
| } | |
| _write_profile_marker | |
| NAMES_DIR="$CLAUDE_DIR/session-names" | |
| PROJECTS_DIR="$CLAUDE_DIR/projects" | |
| SUMMARIES_DIR="$CLAUDE_DIR/session-summaries" | |
| mkdir -p "$NAMES_DIR" "$SUMMARIES_DIR" | |
| # Encode a path to Claude Code's project directory name | |
| # /Users/Z1nk -> -Users-Z1nk | |
| encode_project_path() { | |
| echo "$1" | sed -e 's|/|-|g' -e 's| |-|g' | |
| } | |
| current_project_id() { | |
| encode_project_path "$PWD" | |
| } | |
| names_file() { | |
| local project_id="${1:-default}" | |
| echo "$NAMES_DIR/$project_id.json" | |
| } | |
| get_name() { | |
| local project_id="$1" session_id="$2" | |
| local nf | |
| nf=$(names_file "$project_id") | |
| [ -f "$nf" ] && python3 -c " | |
| import json, sys | |
| d = json.load(open(sys.argv[1])) | |
| print(d.get(sys.argv[2], '')) | |
| " "$nf" "$session_id" 2>/dev/null || echo "" | |
| } | |
| set_name() { | |
| local project_id="$1" session_id="$2" name="$3" | |
| local nf | |
| nf=$(names_file "$project_id") | |
| python3 -c " | |
| import json, os, sys | |
| nf, sid, name = sys.argv[1], sys.argv[2], sys.argv[3] | |
| d = {} | |
| if os.path.exists(nf): | |
| d = json.load(open(nf)) | |
| d[sid] = name | |
| json.dump(d, open(nf, 'w'), indent=2, ensure_ascii=False) | |
| " "$nf" "$session_id" "$name" | |
| } | |
| rm_name() { | |
| local project_id="$1" session_id="$2" | |
| local nf | |
| nf=$(names_file "$project_id") | |
| [ -f "$nf" ] && python3 -c " | |
| import json, os, sys | |
| nf, sid = sys.argv[1], sys.argv[2] | |
| d = json.load(open(nf)) | |
| d.pop(sid, None) | |
| json.dump(d, open(nf, 'w'), indent=2, ensure_ascii=False) | |
| " "$nf" "$session_id" 2>/dev/null | |
| } | |
| # Opt-in: generate Gemini summary for previous session in background | |
| generate_summary_for_latest() { | |
| [ "${CC_AUTO_SUMMARY:-0}" = "1" ] || return 0 | |
| command -v gemini >/dev/null 2>&1 || return 0 | |
| python3 - "$PWD" "$PROJECTS_DIR" "$SUMMARIES_DIR" <<'PYEOF' & | |
| import json, os, sys, glob, subprocess | |
| cwd, projects_dir, summaries_dir = sys.argv[1], sys.argv[2], sys.argv[3] | |
| project_id = cwd.replace("/", "-").replace(" ", "-") | |
| project_dir = os.path.join(projects_dir, project_id) | |
| if not os.path.isdir(project_dir): | |
| sys.exit(0) | |
| sessions = [] | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| sid = os.path.basename(f).replace(".jsonl", "") | |
| sessions.append((sid, f, os.path.getmtime(f))) | |
| sessions.sort(key=lambda x: x[2], reverse=True) | |
| for sid, fpath, _ in sessions[1:2]: | |
| summary_file = os.path.join(summaries_dir, f"{sid}.txt") | |
| if os.path.exists(summary_file): | |
| continue | |
| user_msgs = [] | |
| try: | |
| with open(fpath) as fh: | |
| for line in fh: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| record = json.loads(line) | |
| if record.get("type") != "user": | |
| continue | |
| msg = record.get("message", {}) | |
| content = msg.get("content", "") | |
| text = "" | |
| if isinstance(content, str): | |
| text = content.strip() | |
| elif isinstance(content, list): | |
| for c in content: | |
| if isinstance(c, dict) and c.get("type") == "text": | |
| t = c.get("text", "").strip() | |
| if t: | |
| text = t | |
| break | |
| if text: | |
| user_msgs.append(text[:500]) | |
| except Exception: | |
| continue | |
| except Exception: | |
| continue | |
| if len(user_msgs) < 2: | |
| continue | |
| selected = user_msgs[:5] + user_msgs[-5:] if len(user_msgs) > 10 else user_msgs | |
| prompt_text = "Summarize this conversation in one short sentence (max 50 chars, same language as the conversation). Just output the summary, nothing else.\n\n" | |
| for i, m in enumerate(selected, 1): | |
| prompt_text += f"User message {i}: {m}\n\n" | |
| try: | |
| result = subprocess.run( | |
| ["gemini", "-p", prompt_text], | |
| capture_output=True, text=True, timeout=15 | |
| ) | |
| summary = result.stdout.strip().replace("\n", " ")[:80] | |
| if summary and "error" not in summary.lower()[:20]: | |
| with open(summary_file, "w") as sf: | |
| sf.write(summary) | |
| except Exception: | |
| pass | |
| PYEOF | |
| } | |
| cmd_list() { | |
| local all_projects=false | |
| [ "${1:-}" = "-a" ] && all_projects=true | |
| python3 - "$all_projects" "$PWD" "$PROJECTS_DIR" "$NAMES_DIR" "$SUMMARIES_DIR" <<'PYEOF' | |
| import json, os, sys, glob | |
| from datetime import datetime, timezone | |
| all_projects = sys.argv[1] == "true" | |
| cwd = sys.argv[2] | |
| projects_dir = sys.argv[3] | |
| names_dir = sys.argv[4] | |
| summaries_dir = sys.argv[5] | |
| home = os.path.expanduser("~") | |
| desktop_sessions_dir = os.path.join(home, "Library", "Application Support", "Claude", "claude-code-sessions") | |
| def encode_path(p): | |
| return p.replace("/", "-").replace(" ", "-") | |
| def decode_path(encoded): | |
| if not encoded.startswith("-"): | |
| return encoded | |
| parts = encoded[1:].split("-") | |
| result = "" | |
| i = 0 | |
| while i < len(parts): | |
| for j in range(len(parts), i, -1): | |
| candidate = result + "/" + "-".join(parts[i:j]) | |
| if os.path.isdir(candidate) or j == i + 1: | |
| result = candidate | |
| i = j | |
| break | |
| return result if result else "/" + encoded[1:].replace("-", "/") | |
| def get_display_path(encoded): | |
| decoded = decode_path(encoded) | |
| if decoded.startswith(home): | |
| return "~" + decoded[len(home):] | |
| elif decoded == "/": | |
| return "/" | |
| return decoded | |
| def load_desktop_titles(): | |
| titles = {} | |
| if not os.path.isdir(desktop_sessions_dir): | |
| return titles | |
| for f in glob.glob(os.path.join(desktop_sessions_dir, "*", "*", "*.json")): | |
| try: | |
| d = json.load(open(f)) | |
| cli_sid = d.get("cliSessionId", "") | |
| title = d.get("title", "") | |
| if cli_sid and title: | |
| titles[cli_sid] = title | |
| except Exception: | |
| continue | |
| return titles | |
| desktop_titles = load_desktop_titles() | |
| current_project = encode_path(cwd) | |
| sessions = [] | |
| for project_dir in sorted(glob.glob(os.path.join(projects_dir, "*"))): | |
| if not os.path.isdir(project_dir): | |
| continue | |
| project_id = os.path.basename(project_dir) | |
| if not all_projects and project_id != current_project: | |
| continue | |
| nf = os.path.join(names_dir, f"{project_id}.json") | |
| names = {} | |
| if os.path.exists(nf): | |
| try: | |
| names = json.load(open(nf)) | |
| except Exception: | |
| pass | |
| display_path = get_display_path(project_id) | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| fname = os.path.basename(f) | |
| sid = fname.replace(".jsonl", "") | |
| msg_count = 0 | |
| last_user_msg = "" | |
| last_time = "" | |
| try: | |
| with open(f) as fh: | |
| for line in fh: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| record = json.loads(line) | |
| rtype = record.get("type", "") | |
| ts = record.get("timestamp", "") | |
| if ts: | |
| last_time = ts | |
| if rtype == "user": | |
| msg_count += 1 | |
| msg = record.get("message", {}) | |
| content = msg.get("content", "") | |
| text = "" | |
| if isinstance(content, str): | |
| text = content.replace("\n", " ").strip() | |
| elif isinstance(content, list): | |
| for c in content: | |
| if isinstance(c, dict) and c.get("type") == "text": | |
| text = c.get("text", "").replace("\n", " ").strip() | |
| if text: | |
| break | |
| if text: | |
| last_user_msg = text[:50] | |
| elif rtype == "assistant": | |
| msg_count += 1 | |
| except Exception: | |
| continue | |
| except Exception: | |
| continue | |
| if msg_count == 0: | |
| continue | |
| custom_name = desktop_titles.get(sid, "") or names.get(sid, "") | |
| summary = "" | |
| sf = os.path.join(summaries_dir, f"{sid}.txt") | |
| if os.path.exists(sf): | |
| try: | |
| summary = open(sf).read().strip()[:80] | |
| except Exception: | |
| pass | |
| time_str = "" | |
| if last_time: | |
| try: | |
| if isinstance(last_time, (int, float)): | |
| dt = datetime.fromtimestamp(last_time / 1000, tz=timezone.utc) | |
| else: | |
| dt = datetime.fromisoformat(str(last_time).replace("Z", "+00:00")) | |
| now = datetime.now(timezone.utc) | |
| diff = now - dt | |
| if diff.days > 0: | |
| time_str = f"{diff.days}d ago" | |
| elif diff.seconds > 3600: | |
| time_str = f"{diff.seconds // 3600}h ago" | |
| else: | |
| time_str = f"{diff.seconds // 60}m ago" | |
| except Exception: | |
| time_str = str(last_time)[:16] | |
| sessions.append({ | |
| "project": project_id, | |
| "project_path": display_path, | |
| "sid": sid, | |
| "name": custom_name, | |
| "summary": summary, | |
| "last_msg": last_user_msg, | |
| "time": time_str, | |
| "sort_time": last_time, | |
| "msgs": msg_count, | |
| }) | |
| sessions.sort(key=lambda s: s["sort_time"] if s["sort_time"] else "", reverse=True) | |
| if not sessions: | |
| print(" No sessions found.") | |
| sys.exit(0) | |
| current_proj = "" | |
| for i, s in enumerate(sessions, 1): | |
| if all_projects and s["project"] != current_proj: | |
| current_proj = s["project"] | |
| print(f" \033[0;33m{s['project_path']}\033[0m") | |
| proj = f"\033[0;33m{s['project_path']}\033[0m" if not all_projects else "" | |
| preview = s["summary"] or s["last_msg"] or "(empty)" | |
| line = f" {i:>3}. {s['time']:>8} {s['msgs']:>3} msgs" | |
| if not all_projects: | |
| line += f" {proj}" | |
| if s["name"]: | |
| line += f" \033[1;36m{s['name']}\033[0m" | |
| if s["summary"]: | |
| line += f" ({s['summary'][:40]})" | |
| else: | |
| line += f" {preview}" | |
| print(line) | |
| PYEOF | |
| } | |
| resolve_session() { | |
| local query="$1" | |
| local all_flag="${2:-}" | |
| python3 - "$query" "$PWD" "$all_flag" "$PROJECTS_DIR" "$NAMES_DIR" <<'PYEOF' | head -1 | |
| import json, os, sys, glob | |
| query = sys.argv[1] | |
| cwd = sys.argv[2] | |
| all_flag = sys.argv[3] | |
| projects_dir = sys.argv[4] | |
| names_dir = sys.argv[5] | |
| def encode_path(p): | |
| return p.replace("/", "-").replace(" ", "-") | |
| current_project = encode_path(cwd) | |
| sessions = [] | |
| for project_dir in sorted(glob.glob(os.path.join(projects_dir, "*"))): | |
| if not os.path.isdir(project_dir): | |
| continue | |
| project_id = os.path.basename(project_dir) | |
| if all_flag != "-a" and project_id != current_project: | |
| continue | |
| nf = os.path.join(names_dir, f"{project_id}.json") | |
| names = {} | |
| if os.path.exists(nf): | |
| try: | |
| names = json.load(open(nf)) | |
| except Exception: | |
| pass | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| sid = os.path.basename(f).replace(".jsonl", "") | |
| msg_count = 0 | |
| last_time = "" | |
| try: | |
| with open(f) as fh: | |
| for line in fh: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| r = json.loads(line) | |
| if r.get("type") in ("user", "assistant"): | |
| msg_count += 1 | |
| ts = r.get("timestamp", "") | |
| if ts: | |
| last_time = ts | |
| except Exception: | |
| continue | |
| except Exception: | |
| continue | |
| if msg_count == 0: | |
| continue | |
| custom_name = names.get(sid, "") | |
| sessions.append({"sid": sid, "name": custom_name, "project": project_id, "sort_time": last_time}) | |
| sessions.sort(key=lambda s: s["sort_time"] if s["sort_time"] else "", reverse=True) | |
| try: | |
| idx = int(query) - 1 | |
| if 0 <= idx < len(sessions): | |
| print(sessions[idx]["sid"]) | |
| sys.exit(0) | |
| except ValueError: | |
| pass | |
| # Match by session ID (full UUID or unambiguous prefix, min 4 chars) | |
| if len(query) >= 4 and all(c in "0123456789abcdef-" for c in query.lower()): | |
| q = query.lower() | |
| exact = [s for s in sessions if s["sid"].lower() == q] | |
| if exact: | |
| print(exact[0]["sid"]) | |
| sys.exit(0) | |
| prefix = [s for s in sessions if s["sid"].lower().startswith(q)] | |
| if len(prefix) == 1: | |
| print(prefix[0]["sid"]) | |
| sys.exit(0) | |
| if len(prefix) > 1: | |
| print(f"ERROR: session id prefix '{query}' is ambiguous ({len(prefix)} matches)", file=sys.stderr) | |
| sys.exit(1) | |
| for s in sessions: | |
| if s["name"] == query: | |
| print(s["sid"]) | |
| sys.exit(0) | |
| for s in sessions: | |
| if s["name"] and query.lower() in s["name"].lower(): | |
| print(s["sid"]) | |
| sys.exit(0) | |
| print(f"ERROR: No session found matching '{query}'", file=sys.stderr) | |
| sys.exit(1) | |
| PYEOF | |
| } | |
| resolve_project_for_session() { | |
| local session_id="$1" | |
| python3 -c " | |
| import os, glob, sys | |
| sid = sys.argv[1] | |
| projects_dir = sys.argv[2] | |
| for f in glob.glob(os.path.join(projects_dir, '*', sid + '.jsonl')): | |
| print(os.path.basename(os.path.dirname(f))) | |
| break | |
| " "$session_id" "$PROJECTS_DIR" 2>/dev/null | |
| } | |
| find_newest_session_after() { | |
| local ts="$1" | |
| python3 - "$ts" "$PWD" "$PROJECTS_DIR" <<'PYEOF' | head -1 | |
| import os, sys, glob | |
| ts = int(sys.argv[1]) | |
| cwd = sys.argv[2] | |
| projects_dir = sys.argv[3] | |
| project_id = cwd.replace("/", "-").replace(" ", "-") | |
| project_dir = os.path.join(projects_dir, project_id) | |
| newest = None | |
| newest_time = ts | |
| if os.path.isdir(project_dir): | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| try: | |
| mtime = int(os.path.getmtime(f) * 1000) | |
| if mtime > ts and mtime > newest_time: | |
| newest = os.path.basename(f).replace(".jsonl", "") | |
| newest_time = mtime | |
| except Exception: | |
| continue | |
| if newest: | |
| print(newest) | |
| PYEOF | |
| } | |
| launch_and_name() { | |
| local session_name="$1" | |
| shift | |
| local ts | |
| ts=$(python3 -c "import time; print(int(time.time() * 1000))") | |
| "$CLAUDE_CMD" "$@" | |
| local sid | |
| sid=$(find_newest_session_after "$ts") | |
| if [ -n "$sid" ] && [ -n "$session_name" ]; then | |
| local project_id | |
| project_id=$(resolve_project_for_session "$sid") | |
| if [ -n "$project_id" ]; then | |
| set_name "$project_id" "$sid" "$session_name" | |
| echo "Session saved as: $session_name" | |
| fi | |
| fi | |
| } | |
| case "${1:-}" in | |
| ls) | |
| generate_summary_for_latest | |
| cmd_list "" | |
| ;; | |
| lsa) | |
| generate_summary_for_latest | |
| cmd_list "-a" | |
| ;; | |
| r|resume) | |
| [ -z "${2:-}" ] && echo "Usage: $CMD_NAME r <name|index>" && exit 1 | |
| # Numeric index resolves in current-project scope (matches `cc ls`); | |
| # names/IDs resolve cross-project (matches `cc lsa`). | |
| if [[ "$2" =~ ^[0-9]+$ ]]; then | |
| sid=$(resolve_session "$2" "") | |
| else | |
| sid=$(resolve_session "$2" "-a") | |
| fi | |
| if [ -n "$sid" ]; then | |
| exec "$CLAUDE_CMD" --resume "$sid" | |
| fi | |
| ;; | |
| rn|rename) | |
| { [ -z "${2:-}" ] || [ -z "${3:-}" ]; } && echo "Usage: $CMD_NAME rn <index> <name>" && exit 1 | |
| target="$2" | |
| shift 2 | |
| sid=$(resolve_session "$target" "-a") | |
| if [ -n "$sid" ]; then | |
| project_id=$(resolve_project_for_session "$sid") | |
| set_name "$project_id" "$sid" "$*" | |
| echo "Named session as: $*" | |
| fi | |
| ;; | |
| rm|delete) | |
| [ -z "${2:-}" ] && echo "Usage: $CMD_NAME rm <name|index|session-id>" && exit 1 | |
| sid=$(resolve_session "$2" "-a" 2>/dev/null) | |
| # Fallback: treat arg as a raw session id and locate the file on disk | |
| if [ -z "$sid" ] && [[ "$2" =~ ^[0-9a-fA-F-]{4,}$ ]]; then | |
| match=$(find "$PROJECTS_DIR" -maxdepth 2 -name "$2*.jsonl" 2>/dev/null) | |
| count=$(printf "%s\n" "$match" | grep -c .) | |
| if [ "$count" = "1" ]; then | |
| sid=$(basename "$match" .jsonl) | |
| elif [ "$count" -gt 1 ]; then | |
| echo "ERROR: session id '$2' matches $count files" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [ -n "$sid" ]; then | |
| project_id=$(resolve_project_for_session "$sid") | |
| session_file="$PROJECTS_DIR/$project_id/$sid.jsonl" | |
| session_dir="$PROJECTS_DIR/$project_id/$sid" | |
| [ -f "$session_file" ] && trash "$session_file" | |
| [ -d "$session_dir" ] && trash "$session_dir" | |
| [ -n "$project_id" ] && rm_name "$project_id" "$sid" | |
| echo "Trashed session: $sid" | |
| else | |
| echo "ERROR: No session found matching '$2'" >&2 | |
| exit 1 | |
| fi | |
| ;; | |
| clear) | |
| count=$(find "$PROJECTS_DIR" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ') | |
| echo "This will move $count session file(s) and all name maps to the Trash." | |
| echo -n "Type 'yes' to confirm: " | |
| read -r confirm | |
| if [ "$confirm" = "yes" ]; then | |
| # Collect files into an array so trash gets explicit paths | |
| while IFS= read -r f; do | |
| [ -n "$f" ] && trash "$f" | |
| done < <(find "$PROJECTS_DIR" -name "*.jsonl" 2>/dev/null) | |
| for nf in "$NAMES_DIR"/*.json; do | |
| [ -f "$nf" ] && trash "$nf" | |
| done | |
| echo "All sessions trashed." | |
| else | |
| echo "Cancelled." | |
| fi | |
| ;; | |
| n|new) | |
| [ -z "${2:-}" ] && echo "Usage: $CMD_NAME n <name>" && exit 1 | |
| shift | |
| launch_and_name "$*" | |
| ;; | |
| help|-h|--help) | |
| cat <<EOF | |
| $CMD_NAME - Claude Code wrapper with session naming | |
| Usage: | |
| $CMD_NAME n <name> Start a new session with a name | |
| $CMD_NAME ls List sessions (current project) | |
| $CMD_NAME lsa List sessions across all projects | |
| $CMD_NAME r <name|index> Resume session (numeric index = current project, like 'ls'; | |
| name/UUID = cross-project, like 'lsa') | |
| $CMD_NAME rn <index> <name> Rename a session | |
| $CMD_NAME rm <name|index|id> Trash a session (by name, list index, or session UUID/prefix) | |
| $CMD_NAME clear Trash all sessions (with confirmation) | |
| $CMD_NAME help Show this help | |
| Note: sessions are scoped per profile via CLAUDE_CONFIG_DIR. Two profiles | |
| (e.g. ~/.claude and ~/.claude-2) have independent session lists — switching | |
| profile changes what 'ls' and 'r <index>' see. | |
| Env: | |
| CLAUDE_CONFIG_DIR=$CLAUDE_DIR | |
| CC_AUTO_SUMMARY=${CC_AUTO_SUMMARY:-0} (set to 1 to enable Gemini summaries) | |
| EOF | |
| ;; | |
| "") | |
| # No args: behave like a plain `claude` launcher (matches former alias) | |
| exec "$CLAUDE_CMD" | |
| ;; | |
| *) | |
| echo "Unknown command: $1 (try '$CMD_NAME help')" && exit 1 | |
| ;; | |
| esac |
