-
-
Save zinknovo/5fbd2eddcbf7bc5dd89c0dee59b05eb7 to your computer and use it in GitHub Desktop.
Gemini CLI wrapper with session naming and cross-project listing
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 | |
| # gem - Gemini CLI wrapper with session naming | |
| # Usage: | |
| # gem Start a new Gemini session | |
| # gem ls List sessions with names (current project) | |
| # gem ls -a List sessions across all projects | |
| # gem r <name|index> Resume a session by name or index | |
| # gem rn <index> <name> Rename a session | |
| # gem rm <name|index> Delete a session | |
| set -euo pipefail | |
| # 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/gemini.ver" | |
| local cur_ver | |
| cur_ver=$(gemini --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;33mGemini CLI updated: $last_ver → $cur_ver\033[0m" && check-cli-compat 2>/dev/null || true | |
| fi | |
| } | |
| _check_version & | |
| NAMES_DIR="$HOME/.gemini/session-names" | |
| mkdir -p "$NAMES_DIR" | |
| # Get project hash for current directory (matches Gemini's logic) | |
| get_project_dir() { | |
| local dir | |
| for dir in "$HOME/.gemini/tmp"/*/chats; do | |
| [ -d "$dir" ] && echo "$(dirname "$dir")" && return | |
| done | |
| } | |
| # Find the chats dir for the current working directory | |
| find_chats_dir() { | |
| local found="" | |
| local cwd="$PWD" | |
| # Gemini uses project-specific dirs under ~/.gemini/tmp/ | |
| # We check which project dir's sessions reference our cwd | |
| for dir in "$HOME/.gemini/tmp"/*/chats; do | |
| [ -d "$dir" ] && found="$dir" # fallback to last found | |
| # Check if any session in this dir matches our project | |
| local sample | |
| sample=$(ls -t "$dir"/*.json 2>/dev/null | head -1) | |
| if [ -n "$sample" ]; then | |
| local hash | |
| hash=$(python3 -c "import json; print(json.load(open('$sample')).get('projectHash',''))" 2>/dev/null) | |
| local dirname | |
| dirname=$(basename "$(dirname "$dir")") | |
| # Match by directory name heuristic | |
| local basename_cwd | |
| basename_cwd=$(basename "$cwd") | |
| if [[ "$dirname" == "$basename_cwd"* ]] || [[ "$dirname" == "$hash"* ]]; then | |
| echo "$dir" | |
| return | |
| fi | |
| fi | |
| done | |
| # If no match, check all | |
| echo "" | |
| } | |
| # Get names file for a project | |
| names_file() { | |
| local project_id="${1:-default}" | |
| echo "$NAMES_DIR/$project_id.json" | |
| } | |
| # Read 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 | |
| nf, sid = sys.argv[1], sys.argv[2] | |
| d = json.load(open(nf)) | |
| print(d.get(sid, '')) | |
| " "$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 | |
| } | |
| # List sessions with names | |
| cmd_list() { | |
| local all_projects=false | |
| [ "${1:-}" = "-a" ] && all_projects=true | |
| python3 - "$all_projects" <<'PYEOF' | |
| import json, os, sys, glob | |
| from datetime import datetime, timezone | |
| all_projects = sys.argv[1] == "true" | |
| home = os.path.expanduser("~") | |
| names_dir = os.path.join(home, ".gemini", "session-names") | |
| tmp_dir = os.path.join(home, ".gemini", "tmp") | |
| cwd = os.getcwd() | |
| cwd_base = os.path.basename(cwd) | |
| sessions = [] | |
| for project_dir in sorted(glob.glob(os.path.join(tmp_dir, "*", "chats"))): | |
| project_id = os.path.basename(os.path.dirname(project_dir)) | |
| # Show all projects by default since directory heuristic is unreliable | |
| names_file = os.path.join(names_dir, f"{project_id}.json") | |
| names = {} | |
| if os.path.exists(names_file): | |
| names = json.load(open(names_file)) | |
| # Read actual project root path | |
| project_root_file = os.path.join(os.path.dirname(project_dir), ".project_root") | |
| project_root = project_id | |
| if os.path.exists(project_root_file): | |
| project_root = open(project_root_file).read().strip() | |
| for f in sorted(glob.glob(os.path.join(project_dir, "*.json")), reverse=True): | |
| try: | |
| d = json.load(open(f)) | |
| sid = d.get("sessionId", "") | |
| summary = d.get("summary", "") | |
| start = d.get("startTime", "") | |
| msgs = len(d.get("messages", [])) | |
| custom_name = names.get(sid, "") | |
| # Parse time | |
| time_str = "" | |
| if start: | |
| try: | |
| dt = datetime.fromisoformat(start.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 = start[:16] | |
| sessions.append({ | |
| "project": project_id, | |
| "project_root": project_root, | |
| "sid": sid, | |
| "name": custom_name, | |
| "summary": summary, | |
| "time": time_str, | |
| "msgs": msgs, | |
| }) | |
| except: | |
| continue | |
| if not sessions: | |
| print(" No sessions found.") | |
| sys.exit(0) | |
| # Sort: for lsa group by project then by time; for ls just by time | |
| if all_projects: | |
| # Group by project, within each group sort by time desc | |
| from collections import OrderedDict | |
| groups = OrderedDict() | |
| for s in sessions: | |
| groups.setdefault(s["project"], []).append(s) | |
| sessions = [] | |
| for proj_sessions in groups.values(): | |
| sessions.extend(proj_sessions) | |
| current_project = "" | |
| for i, s in enumerate(sessions, 1): | |
| pr = s['project_root'].replace(home, '~', 1) if s['project_root'].startswith(home) else s['project_root'] | |
| if all_projects and s["project"] != current_project: | |
| current_project = s["project"] | |
| print(f" \033[0;33m{pr}\033[0m") | |
| summary_display = s["summary"][:50] if s["summary"] else "(no summary)" | |
| if s["name"]: | |
| summary_display = s["summary"][:40] if s["summary"] else "" | |
| proj = f"\033[0;33m{pr}\033[0m" if not all_projects else "" | |
| 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 summary_display: | |
| line += f" ({summary_display})" | |
| else: | |
| line += f" {summary_display}" | |
| print(line) | |
| PYEOF | |
| } | |
| # Find session by name or index | |
| resolve_session() { | |
| local query="$1" | |
| python3 - "$query" <<'PYEOF' | head -1 | |
| import json, os, sys, glob | |
| query = sys.argv[1] | |
| home = os.path.expanduser("~") | |
| names_dir = os.path.join(home, ".gemini", "session-names") | |
| tmp_dir = os.path.join(home, ".gemini", "tmp") | |
| cwd_base = os.path.basename(os.getcwd()) | |
| sessions = [] | |
| for project_dir in sorted(glob.glob(os.path.join(tmp_dir, "*", "chats"))): | |
| project_id = os.path.basename(os.path.dirname(project_dir)) | |
| names_file = os.path.join(names_dir, f"{project_id}.json") | |
| names = {} | |
| if os.path.exists(names_file): | |
| names = json.load(open(names_file)) | |
| for f in sorted(glob.glob(os.path.join(project_dir, "*.json")), reverse=True): | |
| try: | |
| d = json.load(open(f)) | |
| sid = d.get("sessionId", "") | |
| custom_name = names.get(sid, "") | |
| sessions.append({"sid": sid, "name": custom_name, "project": project_id}) | |
| except: | |
| continue | |
| # 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 prefix) | |
| 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 - "$session_id" <<'PYEOF' | head -1 | |
| import json, os, glob, sys | |
| sid = sys.argv[1] | |
| home = os.path.expanduser("~") | |
| for f in glob.glob(os.path.join(home, ".gemini", "tmp", "*", "chats", "*.json")): | |
| try: | |
| d = json.load(open(f)) | |
| if d.get("sessionId") == sid: | |
| print(os.path.basename(os.path.dirname(os.path.dirname(f)))) | |
| break | |
| except: | |
| continue | |
| PYEOF | |
| } | |
| # Find the newest session created after a given timestamp | |
| find_newest_session_after() { | |
| local ts="$1" | |
| python3 - "$ts" <<'PYEOF' | head -1 | |
| import json, os, glob, sys | |
| from datetime import datetime, timezone | |
| ts = sys.argv[1] | |
| home = os.path.expanduser("~") | |
| newest = None | |
| newest_time = ts | |
| for f in glob.glob(os.path.join(home, ".gemini", "tmp", "*", "chats", "*.json")): | |
| try: | |
| d = json.load(open(f)) | |
| start = d.get("startTime", "") | |
| if start > ts: | |
| if newest is None or start > newest_time: | |
| newest = d.get("sessionId") | |
| newest_time = start | |
| except: | |
| continue | |
| if newest: | |
| print(newest) | |
| PYEOF | |
| } | |
| # Launch gemini and name the session after exit | |
| launch_and_name() { | |
| local session_name="$1" | |
| shift | |
| local ts | |
| ts=$(date -u +"%Y-%m-%dT%H:%M:%S") | |
| gemini "$@" | |
| # 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) | |
| cmd_list "" | |
| ;; | |
| lsa) | |
| cmd_list "-a" | |
| ;; | |
| r|resume) | |
| [ -z "${2:-}" ] && echo "Usage: gem r <name|index>" && exit 1 | |
| sid=$(resolve_session "$2") | |
| if [ -n "$sid" ]; then | |
| exec gemini --resume "$sid" | |
| fi | |
| ;; | |
| rn|rename) | |
| [ -z "${2:-}" ] || [ -z "${3:-}" ] && echo "Usage: gem rn <index> <name>" && exit 1 | |
| target="$2" | |
| shift 2 | |
| sid=$(resolve_session "$target") | |
| 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: gem rm <name|index>" && exit 1 | |
| sid=$(resolve_session "$2") | |
| if [ -n "$sid" ]; then | |
| gemini --delete-session "$sid" 2>/dev/null || true | |
| project_id=$(resolve_project_for_session "$sid") | |
| [ -n "$project_id" ] && rm_name "$project_id" "$sid" | |
| echo "Deleted session: $sid" | |
| fi | |
| ;; | |
| clear) | |
| echo -n "This will delete ALL Gemini sessions and names. Continue? [y/N] " | |
| read -r -n 1 confirm | |
| echo | |
| if [[ "$confirm" =~ ^[yY]$ ]]; then | |
| find "$HOME/.gemini/tmp" -path "*/chats/*.json" -delete 2>/dev/null | |
| rm -f "$HOME/.gemini/session-names/"*.json 2>/dev/null | |
| echo "All sessions cleared." | |
| else | |
| echo "Cancelled." | |
| fi | |
| ;; | |
| n|new) | |
| [ -z "${2:-}" ] && echo "Usage: gem n <name>" && exit 1 | |
| shift | |
| launch_and_name "$*" | |
| ;; | |
| help|-h|--help) | |
| echo "gem - Gemini CLI wrapper with session naming" | |
| echo "" | |
| echo "Usage:" | |
| echo " gem Start a new Gemini session" | |
| echo " gem n <name> Start a new session with a name" | |
| echo " gem ls List sessions (current project)" | |
| echo " gem lsa List sessions across all projects" | |
| echo " gem r <name|index> Resume session by name or index" | |
| echo " gem rn <index> <name> Rename a session" | |
| echo " gem rm <name|index> Delete a session" | |
| echo " gem clear Delete all sessions" | |
| echo " gem help Show this help" | |
| ;; | |
| "") | |
| echo "Usage: gem <command> [args] (try 'gem help')" | |
| ;; | |
| *) | |
| echo "Unknown command: $1 (try 'gem help')" | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment