Skip to content

Instantly share code, notes, and snippets.

@zinknovo
Created March 15, 2026 22:37
Show Gist options
  • Select an option

  • Save zinknovo/cf21268e90419e9c0e93a9bd448591a4 to your computer and use it in GitHub Desktop.

Select an option

Save zinknovo/cf21268e90419e9c0e93a9bd448591a4 to your computer and use it in GitHub Desktop.
Claude Code wrapper with session naming and auto-summarization
#!/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