Split the POSIX CLI into modules
linux/claude-mode was 3,035 lines. It is now 177: the paths and flags, a loader, and the command dispatch. Everything else moved, verbatim, into twelve files under linux/lib/, one per concern - output, core, preflight, sessions, switch, catalogue, commands, doctor, presets, menu, setup, repair. The move was done by line range with a check that every original line landed in exactly one file; the only thing that changed place is the switch's running-sessions question, which now sits with session detection. The CLI finds lib/, cm-json.py and cm-vault.sh beside itself, following the ~/.local/bin symlink, so a checkout runs its own code rather than the installed version's (it used to mix the two). The key helper path written into settings.json is still the installed one. linux/install.sh ships bin/lib/, clearing old modules first so a removed one cannot linger. The package build copies linux/ recursively, which a flat copy would not. tests/cli/test_install.sh runs the real installer into a sandbox home: every file lands, the symlink runs, a reinstall keeps edited presets and drops a stale module. docs/architecture.md lists the modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
# shellcheck shell=bash
|
||||
# linux/lib/repair.sh - repairing, dismissing and listing session transcripts.
|
||||
#
|
||||
# Part of the claude-mode CLI: sourced by linux/claude-mode, which sets the
|
||||
# CM_* paths, PY and JSON used here. Not meant to run on its own.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript repair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Claude Code files transcripts under ~/.claude/projects/<slug>, and the slug
|
||||
# is not "slashes to dashes": *every* non-alphanumeric character becomes one
|
||||
# dash, nothing collapsed. Verified against 2.1.269 - a directory named
|
||||
# `slug._test x` was filed as `-tmp-cmtest-slug--test-x`, so the dot, the
|
||||
# underscore and the space each became a dash of their own. Paths with a dot in
|
||||
# them are ordinary on macOS (iCloud Drive sits under `Mobile Documents`), and
|
||||
# a slash-only rule points at a directory that does not exist.
|
||||
cm_project_slug() {
|
||||
printf '%s' "$1" | sed 's|[^a-zA-Z0-9]|-|g'
|
||||
}
|
||||
|
||||
# It is also the *physical* directory that gets slugged. Claude Code asks the OS
|
||||
# for its working directory and symlinks come back resolved, so a session
|
||||
# started in /tmp/x is filed under -private-tmp-x on macOS (where /tmp is a
|
||||
# symlink) while the shell's $PWD still reads /tmp/x and looks in -tmp-x.
|
||||
# The logical path is tried first, since that is what a user types and what a
|
||||
# plain project looks like; the resolved one is the fallback.
|
||||
cm_project_dir() {
|
||||
local path="${1:-$PWD}" slug phys
|
||||
slug="$(cm_project_slug "$path")"
|
||||
if [ -d "$CM_SETTINGS_DIR/projects/$slug" ]; then
|
||||
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"; return 0
|
||||
fi
|
||||
phys="$(cd "$path" 2>/dev/null && pwd -P)" || phys=''
|
||||
if [ -n "$phys" ] && [ "$phys" != "$path" ]; then
|
||||
slug="$(cm_project_slug "$phys")"
|
||||
fi
|
||||
printf '%s/projects/%s' "$CM_SETTINGS_DIR" "$slug"
|
||||
}
|
||||
|
||||
# Modification time as an epoch second. GNU stat spells it -c %Y, BSD/macOS
|
||||
# stat spells it -f %m.
|
||||
cm_file_mtime() {
|
||||
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
# Dismissing is bookkeeping, not repair: the transcript is left exactly as it
|
||||
# is, and only whether the scan - and so the bar's warning dot - counts it
|
||||
# changes. Hence no confirmation, and one flag to undo it.
|
||||
cm_ignore_session() {
|
||||
local act="$1" id="${2:-}" out op pruned projects="$CM_SETTINGS_DIR/projects"
|
||||
case "$act" in
|
||||
list)
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" list 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
head_ 'dismissed sessions'
|
||||
printf '%s' "$out" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
D,X='\033[90m','\033[0m'
|
||||
if not d['sessions']:
|
||||
print(' none')
|
||||
for s in d['sessions']:
|
||||
state = 'still broken' if s['broken'] else ('transcript gone' if not s['exists'] else 'no longer broken')
|
||||
print(' %-38s %s' % (s['sessionId'], s['project']))
|
||||
print(' %signored %s, %s%s' % (D, s['ignoredAt'][:10], state, X))
|
||||
print()
|
||||
print(' %sclaude-mode repair-session --unignore <id> (or --unignore-all)%s' % (D,X))
|
||||
"
|
||||
return 0 ;;
|
||||
ignore|unignore)
|
||||
[ -n "$id" ] || { err "--$act needs a session id"; return 1; }
|
||||
[ "$act" = ignore ] && op=add || op=remove ;;
|
||||
unignore-all)
|
||||
op=clear ;;
|
||||
esac
|
||||
|
||||
out="$("$PY" "$JSON" ignore-session "$CM_IGNORED" "$projects" "$op" "${id%.jsonl}" 2>&1)" || {
|
||||
err "$out"; return 1; }
|
||||
id="${id%.jsonl}"
|
||||
case "$act:$out" in
|
||||
ignore:*'"changed": true'*) ok "hidden $id"
|
||||
say "${C_DIM}claude-mode repair-session --unignore $id brings it back${C_RESET}" ;;
|
||||
ignore:*) ok "$id was already hidden" ;;
|
||||
unignore:*'"changed": true'*) ok "restored $id" ;;
|
||||
unignore:*) ok "$id was not hidden" ;;
|
||||
*'"changed": true'*) ok 'restored every dismissed session' ;;
|
||||
*) ok 'nothing was dismissed' ;;
|
||||
esac
|
||||
pruned="$(printf '%s' "$out" | sed -n 's/.*"pruned": \([0-9]*\).*/\1/p')"
|
||||
[ "${pruned:-0}" -gt 0 ] && say "${C_DIM}(forgot $pruned whose transcript no longer exists)${C_RESET}"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_repair_session() {
|
||||
local target='' apply=0 reinject=1 scan_all=0 as_json=0 dir='' a file verdict age
|
||||
local ignore_act='' max_age="${CM_IGNORE_AGE_DAYS:-}"
|
||||
while [ $# -gt 0 ]; do
|
||||
a="$1"; shift
|
||||
case "$a" in
|
||||
--apply) apply=1 ;;
|
||||
--dry-run) apply=0 ;;
|
||||
--no-reinject) reinject=0 ;;
|
||||
--list) target='--list' ;;
|
||||
--all) scan_all=1 ;;
|
||||
--json) as_json=1; scan_all=1 ;;
|
||||
# The id may follow the flag or stand anywhere as the positional, so
|
||||
# `--ignore <id>` and `<id> --ignore` both do what they say.
|
||||
--ignore|--unignore)
|
||||
ignore_act="${a#--}"
|
||||
if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then target="$1"; shift; fi ;;
|
||||
--unignore-all) ignore_act='unignore-all' ;;
|
||||
--ignored) ignore_act='list' ;;
|
||||
--max-age)
|
||||
[ $# -gt 0 ] || { err '--max-age needs a number of days'; return 1; }
|
||||
max_age="$1"; shift ;;
|
||||
-*) err "unknown option '$a'"; return 1 ;;
|
||||
*) target="$a" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$max_age" ] && ! [[ "$max_age" =~ ^[0-9]+$ ]]; then
|
||||
err "max age is a whole number of days, not '$max_age' (--max-age / CM_IGNORE_AGE_DAYS)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$ignore_act" ]; then
|
||||
cm_ignore_session "$ignore_act" "$target"
|
||||
return $?
|
||||
fi
|
||||
|
||||
# A session you need to repair is one you could not resume, which is a poor
|
||||
# position from which to remember which project it belonged to. --all drops
|
||||
# the working-directory scoping and reports only what is actually broken.
|
||||
if [ "$scan_all" -eq 1 ]; then
|
||||
local scan
|
||||
scan="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>&1)" || {
|
||||
err "could not scan transcripts: $scan"; return 1; }
|
||||
|
||||
if [ "$as_json" -eq 1 ]; then
|
||||
printf '%s\n' "$scan"
|
||||
return 0
|
||||
fi
|
||||
|
||||
head_ 'scanning every session transcript'
|
||||
printf '%s' "$scan" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,D,X='\033[32m','\033[33m','\033[90m','\033[0m'
|
||||
for b in d['broken']:
|
||||
print(' %-38s %s' % (b['sessionId'], b['project']))
|
||||
prov = ', '.join(b['providers']) or 'another provider'
|
||||
print(' %d line(s) after the last good message, from %s' % (b['dropLines'], prov))
|
||||
print()
|
||||
ig = d.get('ignored') or []
|
||||
if not d['broken']:
|
||||
print(' %sok %s nothing to repair across %d transcript(s)%s' % (G,X,d['scanned'],
|
||||
' that is not hidden' if ig else ''))
|
||||
else:
|
||||
print(' %swarn%s %d of %d transcript(s) were cut short by a mode switch' % (Y,X,d['count'],d['scanned']))
|
||||
print(' %sclaude-mode repair-session <id> --apply (or --ignore <id> to stop counting it)%s' % (D,X))
|
||||
# Age-hiding must never look like damage vanishing, so whatever was held back
|
||||
# is always named, with the way to see it.
|
||||
if ig:
|
||||
nd = sum(1 for i in ig if i.get('reason') == 'dismissed')
|
||||
ns = len(ig) - nd
|
||||
bits, hints = [], []
|
||||
if nd:
|
||||
bits.append('%d ignored' % nd); hints.append('--ignored lists them')
|
||||
if ns:
|
||||
bits.append('%d older than %d days' % (ns, d.get('maxAgeDays', 0))); hints.append('--max-age 0 shows all')
|
||||
print(' %s%d hidden: %s (%s)%s' % (D, len(ig), ', '.join(bits), '; '.join(hints), X))
|
||||
print(' %ssessions that ran entirely on a gateway are not listed: they carry that%s' % (D,X))
|
||||
print(' %sprovider\'s ids by design and resume fine under it%s' % (D,X))
|
||||
"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Claude Code keys transcripts by the directory the session was started in,
|
||||
# which is rarely the one you are standing in when you come to fix it. Walk
|
||||
# up first, and for a named session fall back to looking through every
|
||||
# project - the id is unique, so there is nothing ambiguous to resolve.
|
||||
local probe="$PWD"
|
||||
while [ -n "$probe" ]; do
|
||||
[ -d "$(cm_project_dir "$probe")" ] && { dir="$(cm_project_dir "$probe")"; break; }
|
||||
[ "$probe" = "/" ] && break
|
||||
probe="$(dirname "$probe")"
|
||||
done
|
||||
|
||||
if [ -n "$target" ] && [ "$target" != "--list" ]; then
|
||||
if [ -z "$dir" ] || [ ! -f "$dir/${target%.jsonl}.jsonl" ]; then
|
||||
local hit
|
||||
hit="$(ls -1 "$CM_SETTINGS_DIR"/projects/*/"${target%.jsonl}".jsonl 2>/dev/null | head -n1)"
|
||||
[ -n "$hit" ] && dir="$(dirname "$hit")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
err 'no session transcripts found for this directory'
|
||||
say 'run it from the project the session belongs to, or name the session id'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$target" ] || [ "$target" = "--list" ]; then
|
||||
head_ 'session transcripts here'
|
||||
# This listing classifies each file itself rather than going through
|
||||
# the scan, so it asks the scan which ones are hidden. It still shows
|
||||
# them - listing everything here is its job - but says why the bar
|
||||
# is not counting them.
|
||||
local f v hidden reason row
|
||||
hidden="$("$PY" "$JSON" scan-sessions "$CM_SETTINGS_DIR/projects" "$max_age" "$CM_IGNORED" 2>/dev/null \
|
||||
| "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
for i in d.get('ignored') or []:
|
||||
print('%s\t%s' % (i['sessionId'], 'ignored' if i.get('reason') == 'dismissed'
|
||||
else 'older than %d days' % d.get('maxAgeDays', 0)))
|
||||
" 2>/dev/null)"
|
||||
for f in $(ls -1t "$dir"/*.jsonl 2>/dev/null); do
|
||||
v="$("$PY" "$JSON" repair-session "$f" 2>/dev/null)" || continue
|
||||
reason=''
|
||||
if row="$(printf '%s\n' "$hidden" | tsv_find "$(basename "$f" .jsonl)")"; then
|
||||
reason="$(tsv_field "$row" 2)"
|
||||
fi
|
||||
printf '%s' "$v" | "$PY" -c "
|
||||
import json,sys,os
|
||||
d=json.load(sys.stdin)
|
||||
state = 'ok' if d['healthy'] else ('repairable, would drop %d line(s)' % d['dropLines'] if d['repairable'] else 'no anthropic message found')
|
||||
if sys.argv[1]:
|
||||
state += ' (hidden: %s)' % sys.argv[1]
|
||||
print(' %-40s %s' % (os.path.basename(d['path'])[:-6], state))
|
||||
" "$reason"
|
||||
done
|
||||
printf '\n %sclaude-mode repair-session <session-id> --apply%s\n' "$C_DIM" "$C_RESET"
|
||||
printf ' %s--all checks every project, not just this one%s\n' "$C_DIM" "$C_RESET"
|
||||
return 0
|
||||
fi
|
||||
|
||||
file="$dir/${target%.jsonl}.jsonl"
|
||||
[ -f "$file" ] || { err "no transcript $file"; return 1; }
|
||||
|
||||
# A transcript that is still being appended to belongs to a session that is
|
||||
# still alive; truncating it underneath a running process helps nobody.
|
||||
age=$(( $(date +%s) - $(cm_file_mtime "$file") ))
|
||||
if [ "$age" -lt 90 ] && [ "$apply" -eq 1 ]; then
|
||||
err "that transcript was written to ${age}s ago - it looks live"
|
||||
say 'close the session that owns it first'
|
||||
return 1
|
||||
fi
|
||||
|
||||
local flags=''
|
||||
[ "$apply" -eq 1 ] && flags="$flags --apply"
|
||||
[ "$reinject" -eq 0 ] && flags="$flags --no-reinject"
|
||||
# shellcheck disable=SC2086
|
||||
verdict="$("$PY" "$JSON" repair-session "$file" $flags)" || {
|
||||
err 'could not read that transcript'; return 1; }
|
||||
|
||||
# A repaired session is no longer damage. Left dismissed, it would stay
|
||||
# hidden if the same session broke again later.
|
||||
case "$verdict" in
|
||||
*'"applied": true'*)
|
||||
"$PY" "$JSON" ignore-session "$CM_IGNORED" "$CM_SETTINGS_DIR/projects" \
|
||||
remove "${target%.jsonl}" >/dev/null 2>&1 ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$verdict" | "$PY" -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
G,Y,R,D,X = '\033[32m','\033[33m','\033[91m','\033[90m','\033[0m'
|
||||
print()
|
||||
if d['healthy']:
|
||||
print(' %sok %s last message is Anthropic-issued; nothing to repair' % (G,X))
|
||||
raise SystemExit(0)
|
||||
if d['kind'] == 'gateway-native':
|
||||
prov = (d['foreignIds'][0]['model'] or 'a gateway') if d['foreignIds'] else 'a gateway'
|
||||
print(' %sok %s this session ran entirely on %s' % (G,X,prov))
|
||||
print(' %sits ids come from that provider by design; it resumes under it, not%s' % (D,X))
|
||||
print(' %sunder Anthropic. There is nothing here to repair.%s' % (D,X))
|
||||
raise SystemExit(0)
|
||||
if not d['repairable']:
|
||||
print(' %sok %s this transcript has no assistant replies to resume from' % (G,X))
|
||||
raise SystemExit(0)
|
||||
for f in d['foreignIds']:
|
||||
print(' %swarn%s line %d carries a %s id from %s' % (Y,X,f['line'],f['id'].split('-')[0]+'-',f['model'] or 'another provider'))
|
||||
n = sum(1 for s in d['syntheticIds'] if s['apiError'] and s['line'] > d['lastGoodLine'])
|
||||
if n:
|
||||
print(' %swarn%s %d client-side error placeholder(s) after the last good message' % (Y,X,n))
|
||||
if d['applied']:
|
||||
print(' %sok %s truncated to line %d, dropping %d' % (G,X,d['lastGoodLine'],d['dropLines']))
|
||||
print(' %sok %s original saved as %s' % (G,X,d['backup']))
|
||||
if d.get('recovered'):
|
||||
print(' %sok %s dropped turns written to %s' % (G,X,d['recovered']))
|
||||
if d.get('reinjected'):
|
||||
print(' %sok %s and handed back to the session as a context note' % (G,X))
|
||||
print()
|
||||
print(' %sthat session should resume, and will know what it did%s' % (D,X))
|
||||
else:
|
||||
print(' %swarn%s would truncate to line %d, dropping %d line(s)' % (Y,X,d['lastGoodLine'],d['dropLines']))
|
||||
print()
|
||||
print(' %sre-run with --apply: the original is backed up, the dropped turns are%s' % (D,X))
|
||||
print(' %ssaved as markdown, and handed back to the session as a context note%s' % (D,X))
|
||||
print(' %s(--no-reinject writes the file but leaves the session untouched)%s' % (D,X))
|
||||
"
|
||||
}
|
||||
Reference in New Issue
Block a user