#!/bin/bash # find-cc-tool-output - Recover full tool_result output from Claude Code transcripts # # Usage: # find-cc-tool-output [options] # # Options: # -s, --session Limit to one session (custom title or UUID) # -d, --dir Limit to one project directory # -m, --match Print the Nth unique output from a multi-match listing # -a, --all Dump every unique matching body # -i, --include-meta Don't filter out this script's own listing output # -v, --verbose Verbose progress output # -h, --help Show help message _find_cc_tool_output() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="find-cc-tool-output" ;; esac local PROJECTS_DIR; PROJECTS_DIR="$HOME/.claude/projects" local SNIPPET_LEN=120 _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _show_help() { local s; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' cat < $SCRIPT_NAME -h DESCRIPTION Searches ~/.claude/projects/ JSONL transcripts for tool_result entries whose content contains <${s}substring${r}>. Useful for recovering full Bash/Read output from a previous session when the CLI's '+N lines (ctrl+o)' display cap can't be expanded. Matches are deduplicated by exact body text. When all matches share the same body, it's printed to stdout. When bodies differ, each unique body is listed on stderr with all its occurrences and the script exits 2 -- pick one with -m/--match ${s}N${r}, narrow with --session or --dir, or pass --all to dump every unique body. OPTIONS -s, --session ${s}name|uuid${r} Limit to one session. Friendly names (set via /rename) are resolved by latest customTitle event -d, --dir ${s}path${r} Limit to one project directory. Accepts absolute paths (encoded automatically) or the already-encoded basename -m, --match ${s}N${r} Print the Nth unique output from the previous listing (1-based). Mutex with --all -a, --all Dump every unique body, separated by headers -i, --include-meta Don't filter out this script's own listing output (which otherwise self-matches when captured by a later tool call) -v, --verbose Verbose progress output -h, --help Show this help message DEPENDENCIES python3 EXIT STATUS 0 Match found and printed (or all dumped) 1 No matches 2 Usage error, or multiple unique outputs without --match/--all 3 Missing dependency EXAMPLES # Recover output containing a unique string $SCRIPT_NAME 'PARENS ACRONYMS' # When several unique outputs match, pick one from the listing $SCRIPT_NAME --match 2 'PARENS ACRONYMS' # Restrict to one renamed session $SCRIPT_NAME -s 'dsc-scrape audit' 'landings on disk' # Restrict to one project directory $SCRIPT_NAME -d /x 'sips -Z' # Dump every unique body $SCRIPT_NAME --all 'curl -fSsiL' CAVEATS Substring matching is performed on decoded JSON content, so quoted strings in the original output match literally. Substrings spanning raw newlines work; substrings containing literal backslashes may not. Encoded project basenames start with '-', which collides with the short-flag bundling syntax. Use the '=' form for those: $SCRIPT_NAME --dir=-Users-me--x foo # works $SCRIPT_NAME --dir -Users-me--x foo # parsed as bundled flags Absolute paths are encoded automatically and have no such issue. EOF } _expand_short_opts() { local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } local session="" local dir="" local all_mode=0 local match_n="" local include_meta=0 local verbose=0 local args=() _expand_short_opts "sdm" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help; return 0 ;; -v|--verbose) verbose=1; shift ;; -a|--all) all_mode=1; shift ;; -i|--include-meta) include_meta=1; shift ;; -s|--session) [ -n "${2-}" ] || { _error "--session requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } session="$2"; shift 2 ;; --session=*) session="${1#*=}" [ -n "$session" ] || { _error "--session requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -d|--dir) [ -n "${2-}" ] || { _error "--dir requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } dir="$2"; shift 2 ;; --dir=*) dir="${1#*=}" [ -n "$dir" ] || { _error "--dir requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -m|--match) [ -n "${2-}" ] || { _error "--match requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } match_n="$2"; shift 2 ;; --match=*) match_n="${1#*=}" [ -n "$match_n" ] || { _error "--match requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; --) shift; while [ $# -gt 0 ]; do args+=("$1"); shift; done ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; *) args+=("$1"); shift ;; esac done if [ "${#args[@]}" -eq 0 ]; then _error "Must provide a substring to search for. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi if [ "${#args[@]}" -gt 1 ]; then _error "Multiple substrings not allowed (got ${#args[@]}). Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local substring; substring="${args[0]}" if [ -n "$match_n" ] && [ "$all_mode" -eq 1 ]; then _error "--match and --all are mutually exclusive. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi if [ -n "$match_n" ]; then case "$match_n" in ''|*[!0-9]*) _error "--match value must be a positive integer, got '$match_n'. Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac if [ "$match_n" -lt 1 ]; then _error "--match value must be >= 1, got '$match_n'. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi fi if ! command -v python3 >/dev/null 2>&1; then _error "python3 is required" return 3 fi if [ ! -d "$PROJECTS_DIR" ]; then _error "$PROJECTS_DIR does not exist" return 1 fi PROJECTS_DIR="$PROJECTS_DIR" \ SUBSTRING="$substring" \ SESSION="$session" \ DIR_ARG="$dir" \ ALL_MODE="$all_mode" \ MATCH_N="$match_n" \ INCLUDE_META="$include_meta" \ VERBOSE="$verbose" \ SNIPPET_LEN="$SNIPPET_LEN" \ SCRIPT_NAME="$SCRIPT_NAME" \ python3 <<'PY' import os, sys, json from pathlib import Path projects_dir = Path(os.environ["PROJECTS_DIR"]) substring = os.environ["SUBSTRING"] session = os.environ.get("SESSION", "") dir_arg = os.environ.get("DIR_ARG", "") all_mode = os.environ.get("ALL_MODE") == "1" match_n_raw = os.environ.get("MATCH_N", "") match_n = int(match_n_raw) if match_n_raw else 0 include_meta = os.environ.get("INCLUDE_META") == "1" verbose = os.environ.get("VERBOSE") == "1" snippet_len = int(os.environ.get("SNIPPET_LEN", "120")) script_name = os.environ.get("SCRIPT_NAME", "find-cc-tool-output") # tool_result text starting with this prefix is this script's own listing # captured by a later tool call -- skip unless --include-meta META_PREFIX = f"[INF][{script_name}]" def err(msg): print(f"[ERR][{script_name}] {msg}", file=sys.stderr) def info(msg): if verbose: print(f"[INF][{script_name}] {msg}", file=sys.stderr) def encode_path(p): # CC encodes project dirs by replacing both `/` and `.` with `-` return p.replace("/", "-").replace(".", "-") # Resolve --dir (absolute path -> encoded; otherwise treat as already-encoded) dir_filter = "" if dir_arg: dir_filter = encode_path(dir_arg) if dir_arg.startswith("/") else dir_arg info(f"dir filter: '{dir_filter}'") if not (projects_dir / dir_filter).is_dir(): err(f"Project directory not found: {projects_dir / dir_filter}") sys.exit(1) # Build candidate file list if dir_filter: candidate_dirs = [projects_dir / dir_filter] else: candidate_dirs = sorted(p for p in projects_dir.iterdir() if p.is_dir()) candidate_files = [] for d in candidate_dirs: candidate_files.extend(sorted(d.glob("*.jsonl"))) info(f"scanning {len(candidate_files)} JSONL file(s)") # Resolve --session def looks_uuid(s): if len(s) != 36: return False parts = s.split("-") return len(parts) == 5 and [len(p) for p in parts] == [8, 4, 4, 4, 12] if session: if looks_uuid(session): candidate_files = [f for f in candidate_files if f.stem == session] if not candidate_files: err(f"Session UUID not found: {session}") sys.exit(1) else: # Match files whose latest customTitle equals `session` matching = [] for f in candidate_files: latest = None try: with f.open("r", encoding="utf-8", errors="replace") as fh: for line in fh: try: obj = json.loads(line) except Exception: continue if obj.get("type") == "custom-title": latest = obj.get("customTitle") except Exception: continue if latest == session: matching.append(f) if not matching: err(f"No session named: {session}") sys.exit(1) candidate_files = matching info(f"after session filter: {len(candidate_files)} file(s)") def extract_tool_result_text(obj): """Return the tool_result body text, or None if this entry isn't a tool_result""" msg = obj.get("message", {}) content = msg.get("content") if not isinstance(content, list): return None parts = [] found = False for c in content: if not isinstance(c, dict) or c.get("type") != "tool_result": continue found = True body = c.get("content") if isinstance(body, str): parts.append(body) elif isinstance(body, list): for b in body: if isinstance(b, dict): parts.append(b.get("text", "") or "") if not found: return None return "\n".join(parts) # (file, line_no, custom_title_at_line, text) matches = [] meta_skipped = 0 for f in candidate_files: custom_title = None try: with f.open("r", encoding="utf-8", errors="replace") as fh: for line_no, line in enumerate(fh, 1): try: obj = json.loads(line) except Exception: continue if obj.get("type") == "custom-title": custom_title = obj.get("customTitle") continue if obj.get("type") != "user": continue text = extract_tool_result_text(obj) if text is None: continue if substring not in text: continue if not include_meta and text.startswith(META_PREFIX): meta_skipped += 1 continue matches.append((f, line_no, custom_title, text)) except Exception: continue info(f"found {len(matches)} match(es); skipped {meta_skipped} self-match(es)") if not matches: err(f"No matches for: {substring}") if meta_skipped > 0: err(f"({meta_skipped} self-match(es) hidden; pass --include-meta to include)") sys.exit(1) # Group by exact body text. Preserve first-seen order so listing IDs are stable groups = {} # text -> list of (file, line_no, custom_title) group_order = [] # text values in insertion order for f, line_no, title, text in matches: if text not in groups: groups[text] = [] group_order.append(text) groups[text].append((f, line_no, title)) n_groups = len(group_order) total_occurrences = len(matches) info(f"{n_groups} unique output(s) across {total_occurrences} occurrence(s)") def format_locations(locs): """Return [' proj/uuid L42 (title)', ...] for each location""" lines = [] for f, line_no, title in locs: label = f" {f.parent.name}/{f.stem} L{line_no}" if title: label += f" ({title})" lines.append(label) return lines # Single unique output -> print body, mention occurrence count if >1 if n_groups == 1 and not all_mode and not match_n: text = group_order[0] if total_occurrences > 1: info(f"1 unique output across {total_occurrences} occurrence(s); printing") print(text) sys.exit(0) # --match N: pick the Nth unique output if match_n: if match_n > n_groups: err(f"--match {match_n} out of range: only {n_groups} unique output(s) available") sys.exit(2) text = group_order[match_n - 1] locs = groups[text] info(f"--match {match_n}: {len(locs)} occurrence(s)") print(text) sys.exit(0) # --all: dump every unique body if all_mode: for i, text in enumerate(group_order): locs = groups[text] sep = "=" * 60 print(sep, file=sys.stderr) print(f"[{i+1}/{n_groups}] {len(locs)} occurrence(s):", file=sys.stderr) for line in format_locations(locs): print(line, file=sys.stderr) print(sep, file=sys.stderr) print(text) if i < n_groups - 1: print() sys.exit(0) # Multi-group without --match/--all: list groups and exit 2 hint = "pick one with --match, narrow with --session/--dir, or pass --all" print(f"[INF][{script_name}] {n_groups} unique output(s) across {total_occurrences} occurrence(s) ({hint}):", file=sys.stderr) for i, text in enumerate(group_order): locs = groups[text] snippet = " ".join(text.split()) if len(snippet) > snippet_len: snippet = snippet[:snippet_len] + "..." print(f" [{i+1}] {len(locs)} occurrence(s):", file=sys.stderr) for line in format_locations(locs): print(f" {line}", file=sys.stderr) print(f" \"{snippet}\"", file=sys.stderr) sys.exit(2) PY ) _find_cc_tool_output "$@" __find_cc_tool_output_rc=$? unset -f _find_cc_tool_output if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __find_cc_tool_output_rc; return $__find_cc_tool_output_rc" fi eval "unset __find_cc_tool_output_rc; exit $__find_cc_tool_output_rc"