-
-
Save zinknovo/cf21268e90419e9c0e93a9bd448591a4 to your computer and use it in GitHub Desktop.
Claude Code wrapper with session naming and auto-summarization
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 bash | |
| # cc - Claude Code wrapper with session naming | |
| # 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 ls -a 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 a session | |
| # cc clear Delete all sessions | |
| set -euo pipefail | |
| # Allow overriding the underlying command and display name (used by hap/holo wrappers) | |
| CLAUDE_CMD="${CLAUDE_CMD:-claude}" | |
| CMD_NAME="${CMD_NAME:-$(basename "$0")}" | |
| # Auto-check compatibility on version change | |
| _check_version() { | |
| local compat_dir="$HOME/.local/share/cli-compat" | |
| mkdir -p "$compat_dir" | |
| local ver_file="$compat_dir/claude.ver" | |
| local cur_ver | |
| cur_ver=$(claude --version 2>/dev/null | head -1 || echo "unknown") | |
| local last_ver="" | |
| [ -f "$ver_file" ] && last_ver=$(cat "$ver_file") | |
| if [ "$cur_ver" != "$last_ver" ]; then | |
| echo "$cur_ver" > "$ver_file" | |
| [ -n "$last_ver" ] && echo -e "\033[0;33mClaude Code updated: $last_ver → $cur_ver\033[0m" && check-cli-compat 2>/dev/null || true | |
| fi | |
| } | |
| _check_version & | |
| NAMES_DIR="$HOME/.claude/session-names" | |
| PROJECTS_DIR="$HOME/.claude/projects" | |
| SUMMARIES_DIR="$HOME/.claude/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 's|/|-|g' | |
| } | |
| # Get the project directory name for cwd | |
| current_project_id() { | |
| encode_project_path "$PWD" | |
| } | |
| # Decode project dir name back to path | |
| decode_project_path() { | |
| local encoded="$1" | |
| # -Users-Z1nk -> /Users/Z1nk | |
| echo "$encoded" | sed 's|^-|/|; s|-|/|g' | |
| } | |
| # Names file for a project | |
| names_file() { | |
| local project_id="${1:-default}" | |
| echo "$NAMES_DIR/$project_id.json" | |
| } | |
| # Get name for a session | |
| 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 for a session | |
| 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" | |
| } | |
| # Remove name for a session | |
| 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 | |
| } | |
| # Generate summary for the most recent session that lacks one (like Gemini's approach) | |
| # Runs in background, fire-and-forget | |
| generate_summary_for_latest() { | |
| python3 - "$PWD" <<'PYEOF' & | |
| import json, os, sys, glob, subprocess | |
| cwd = sys.argv[1] | |
| home = os.path.expanduser("~") | |
| projects_dir = os.path.join(home, ".claude", "projects") | |
| summaries_dir = os.path.join(home, ".claude", "session-summaries") | |
| project_id = cwd.replace("/", "-") | |
| project_dir = os.path.join(projects_dir, project_id) | |
| if not os.path.isdir(project_dir): | |
| sys.exit(0) | |
| # Find all sessions sorted by mtime descending | |
| sessions = [] | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| sid = os.path.basename(f).replace(".jsonl", "") | |
| mtime = os.path.getmtime(f) | |
| sessions.append((sid, f, mtime)) | |
| sessions.sort(key=lambda x: x[2], reverse=True) | |
| # Find the most recent session without a summary (skip the current/newest one) | |
| for sid, fpath, _ in sessions[1:2]: # only check the 2nd most recent (previous session) | |
| summary_file = os.path.join(summaries_dir, f"{sid}.txt") | |
| if os.path.exists(summary_file): | |
| continue # already has summary | |
| # Extract user messages: first 5 + last 5 | |
| user_msgs = [] | |
| msg_count = 0 | |
| 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]) | |
| msg_count += 1 | |
| except: | |
| continue | |
| except: | |
| continue | |
| if msg_count < 2: | |
| continue # too short to summarize | |
| # Take first 5 + last 5 messages | |
| if len(user_msgs) > 10: | |
| selected = user_msgs[:5] + user_msgs[-5:] | |
| else: | |
| selected = 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: | |
| pass | |
| PYEOF | |
| } | |
| # List sessions | |
| cmd_list() { | |
| local all_projects=false | |
| [ "${1:-}" = "-a" ] && all_projects=true | |
| python3 - "$all_projects" "$PWD" <<'PYEOF' | |
| import json, os, sys, glob | |
| from datetime import datetime, timezone | |
| all_projects = sys.argv[1] == "true" | |
| cwd = sys.argv[2] | |
| home = os.path.expanduser("~") | |
| projects_dir = os.path.join(home, ".claude", "projects") | |
| names_dir = os.path.join(home, ".claude", "session-names") | |
| summaries_dir = os.path.join(home, ".claude", "session-summaries") | |
| desktop_sessions_dir = os.path.join(home, "Library", "Application Support", "Claude", "claude-code-sessions") | |
| def encode_path(p): | |
| return p.replace("/", "-") | |
| def decode_path(encoded): | |
| """Decode project dir name back to real path by checking filesystem.""" | |
| 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(): | |
| """Load session titles from Claude Desktop's claude-code-sessions.""" | |
| titles = {} # cliSessionId -> title | |
| 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: | |
| 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 | |
| # Load names | |
| nf = os.path.join(names_dir, f"{project_id}.json") | |
| names = {} | |
| if os.path.exists(nf): | |
| try: | |
| names = json.load(open(nf)) | |
| except: | |
| 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: | |
| continue | |
| except: | |
| continue | |
| if msg_count == 0: | |
| continue | |
| # Priority: desktop title > cc name > summary > last user msg | |
| custom_name = desktop_titles.get(sid, "") or names.get(sid, "") | |
| # Load summary if exists | |
| summary = "" | |
| sf = os.path.join(summaries_dir, f"{sid}.txt") | |
| if os.path.exists(sf): | |
| try: | |
| summary = open(sf).read().strip()[:80] | |
| except: | |
| pass | |
| # Parse timestamp | |
| 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: | |
| 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, | |
| }) | |
| # Sort by time descending | |
| 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 "" | |
| # Fallback chain: name > summary > last msg | |
| 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 by name or index | |
| resolve_session() { | |
| local query="$1" | |
| local all_flag="${2:-}" | |
| python3 - "$query" "$PWD" "$all_flag" <<'PYEOF' | head -1 | |
| import json, os, sys, glob | |
| query = sys.argv[1] | |
| cwd = sys.argv[2] | |
| all_flag = sys.argv[3] | |
| home = os.path.expanduser("~") | |
| projects_dir = os.path.join(home, ".claude", "projects") | |
| names_dir = os.path.join(home, ".claude", "session-names") | |
| def encode_path(p): | |
| return p.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: | |
| pass | |
| for f in glob.glob(os.path.join(project_dir, "*.jsonl")): | |
| sid = os.path.basename(f).replace(".jsonl", "") | |
| # Get msg count quickly | |
| 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: | |
| continue | |
| except: | |
| 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 by index | |
| try: | |
| idx = int(query) - 1 | |
| if 0 <= idx < len(sessions): | |
| print(sessions[idx]["sid"]) | |
| sys.exit(0) | |
| except ValueError: | |
| pass | |
| # Try by name (exact match first, then substring) | |
| 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 id for a session | |
| resolve_project_for_session() { | |
| local session_id="$1" | |
| python3 -c " | |
| import os, glob, sys | |
| sid = sys.argv[1] | |
| home = os.path.expanduser('~') | |
| for f in glob.glob(os.path.join(home, '.claude', 'projects', '*', sid + '.jsonl')): | |
| print(os.path.basename(os.path.dirname(f))) | |
| break | |
| " "$session_id" 2>/dev/null | |
| } | |
| # Find newest session after timestamp | |
| find_newest_session_after() { | |
| local ts="$1" | |
| python3 - "$ts" "$PWD" <<'PYEOF' | head -1 | |
| import json, os, sys, glob | |
| ts = int(sys.argv[1]) | |
| cwd = sys.argv[2] | |
| home = os.path.expanduser("~") | |
| project_id = cwd.replace("/", "-") | |
| project_dir = os.path.join(home, ".claude", "projects", 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: | |
| continue | |
| if newest: | |
| print(newest) | |
| PYEOF | |
| } | |
| # Launch claude and name the session after exit | |
| launch_and_name() { | |
| local session_name="$1" | |
| shift | |
| local ts | |
| ts=$(python3 -c "import time; print(int(time.time() * 1000))") | |
| $CLAUDE_CMD "$@" | |
| # After exit, find the session and name it | |
| 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 | |
| } | |
| # Main | |
| case "${1:-}" in | |
| ls) | |
| generate_summary_for_latest | |
| cmd_list "" | |
| ;; | |
| lsa) | |
| generate_summary_for_latest | |
| cmd_list "-a" | |
| ;; | |
| r|resume) | |
| [ -z "${2:-}" ] && echo "Usage: cc r <name|index>" && exit 1 | |
| sid=$(resolve_session "$2" "-a") | |
| 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: cc rm <name|index>" && exit 1 | |
| sid=$(resolve_session "$2" "-a") | |
| if [ -n "$sid" ]; then | |
| project_id=$(resolve_project_for_session "$sid") | |
| # Delete JSONL file and session directory | |
| session_file="$PROJECTS_DIR/$project_id/$sid.jsonl" | |
| session_dir="$PROJECTS_DIR/$project_id/$sid" | |
| [ -f "$session_file" ] && rm -f "$session_file" | |
| [ -d "$session_dir" ] && rm -rf "$session_dir" | |
| [ -n "$project_id" ] && rm_name "$project_id" "$sid" | |
| echo "Deleted session: $sid" | |
| fi | |
| ;; | |
| clear) | |
| echo -n "Delete ALL Claude Code sessions and names? [y/N] " | |
| read -r -n 1 confirm | |
| echo | |
| if [[ "$confirm" =~ ^[yY]$ ]]; then | |
| find "$PROJECTS_DIR" -name "*.jsonl" -delete 2>/dev/null | |
| rm -f "$NAMES_DIR/"*.json 2>/dev/null | |
| echo "All sessions cleared." | |
| else | |
| echo "Cancelled." | |
| fi | |
| ;; | |
| n|new) | |
| [ -z "${2:-}" ] && echo "Usage: $CMD_NAME n <name>" && exit 1 | |
| shift | |
| launch_and_name "$*" | |
| ;; | |
| help|-h|--help) | |
| echo "$CMD_NAME - Claude Code wrapper with session naming" | |
| echo "" | |
| echo "Usage:" | |
| echo " $CMD_NAME n <name> Start a new session with a name" | |
| echo " $CMD_NAME ls List sessions (current project)" | |
| echo " $CMD_NAME lsa List sessions across all projects" | |
| echo " $CMD_NAME r <name|index> Resume session by name or index" | |
| echo " $CMD_NAME rn <index> <name> Rename a session" | |
| echo " $CMD_NAME rm <name|index> Delete a session" | |
| echo " $CMD_NAME clear Delete all sessions" | |
| echo " $CMD_NAME help Show this help" | |
| ;; | |
| "") | |
| echo "Usage: $CMD_NAME <command> [args] (try '$CMD_NAME help')" | |
| ;; | |
| *) | |
| echo "Unknown command: $1 (try '$CMD_NAME help')" && exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment