diff --git a/.chezmoiignore.tmpl b/.chezmoiignore.tmpl index 29ce116..fded56c 100644 --- a/.chezmoiignore.tmpl +++ b/.chezmoiignore.tmpl @@ -54,3 +54,9 @@ dot_local/bin/executable_voxtype-smart-toggle dot_local/bin/executable_voxtype-cleanup dot_local/share/overrides/bin/executable_ydotool {{ end }} + +{{ if not .work }} +# Work-only: aifx + claude settings-repair overrides (paths are target-relative) +.local/share/overrides/bin/claude +.local/share/overrides/bin/aifx +{{ end }} diff --git a/dot_local/share/overrides/bin/executable_aifx b/dot_local/share/overrides/bin/executable_aifx new file mode 100644 index 0000000..a75900c --- /dev/null +++ b/dot_local/share/overrides/bin/executable_aifx @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -uo pipefail + +# aifx proxy: keeps the claude settings-repair override in the loop. The override +# dir is first on PATH, so this shadows the real aifx in any shell (and for hook/ +# tool calls). It owns ~/.local/bin/claude (aifx's first binary candidate) as an +# ephemeral symlink — created before invoking aifx, removed on exit. Find the real +# aifx on PATH by canonical path, excluding ourselves. +_canon() { realpath "$1" 2>/dev/null || readlink -f "$1" 2>/dev/null || printf '%s\n' "$1"; } +SELF="$(_canon "$0")" +REAL_AIFX="" +while IFS= read -r _cand; do + [[ "$(_canon "$_cand")" != "$SELF" ]] && { REAL_AIFX="$_cand"; break; } +done < <(type -aP aifx 2>/dev/null) +[[ -n "$REAL_AIFX" ]] || { echo "aifx-override: no real aifx on PATH" >&2; exit 1; } + +LINK="$HOME/.local/bin/claude" +OVERRIDE="$(dirname "$SELF")/claude" # sibling claude override +remove_link() { rm -f "$LINK"; } + +# Run aifx with ~/.local/bin/claude set to $1 ("" => absent) for the duration, +# then remove the link however aifx exits. +run_with_link() { + local target="$1" + shift + if [[ -n "$target" ]]; then ln -sf "$target" "$LINK"; else remove_link; fi + trap remove_link EXIT + "$REAL_AIFX" "$@" + exit $? +} + +# run: route through the override + force --no-update (updates come from the +# package manager). install/update: keep the link absent so aifx resolves the real +# binary via its own candidate list. Everything else passes straight through. +if [[ "${1:-}" == "agent" && "${3:-}" == "claude" ]]; then + case "${2:-}" in + run) run_with_link "$OVERRIDE" agent run claude --no-update "${@:4}" ;; + install | update) run_with_link "" agent "$2" claude "${@:4}" ;; + esac +fi + +exec "$REAL_AIFX" "$@" diff --git a/dot_local/share/overrides/bin/executable_claude b/dot_local/share/overrides/bin/executable_claude new file mode 100644 index 0000000..76ec64a --- /dev/null +++ b/dot_local/share/overrides/bin/executable_claude @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# claude proxy: preserve the user's settings.json deltas across aifx's enforced +# merge. Snapshot the enforced baseline, replay tracked deltas before launch, then +# re-derive them on exit (so claude's shutdown writes like /model-save are caught). +# Reached directly and via the ephemeral ~/.local/bin/claude symlink, so find the +# real claude on PATH by canonical path, excluding ourselves. +_canon() { realpath "$1" 2>/dev/null || readlink -f "$1" 2>/dev/null || printf '%s\n' "$1"; } +SELF="$(_canon "$0")" +REAL_CLAUDE="" +while IFS= read -r _cand; do + [[ "$(_canon "$_cand")" != "$SELF" ]] && { REAL_CLAUDE="$_cand"; break; } +done < <(type -aP claude 2>/dev/null) +[[ -n "$REAL_CLAUDE" ]] || { echo "claude-override: no real claude on PATH" >&2; exit 1; } + +SETTINGS="$HOME/.claude/settings.json" +USER_CHANGED="$HOME/.claude/settings.user-changed.json" +CLEAN_SNAPSHOT="$HOME/.claude/settings.clean.json" + +# Snapshot the enforced baseline, then replay user deltas (null value = delete key). +if [[ -f "$SETTINGS" ]]; then + cp "$SETTINGS" "$CLEAN_SNAPSHOT" 2>/dev/null || true +fi +if [[ -f "$SETTINGS" && -f "$USER_CHANGED" ]]; then + tmp=$(mktemp) + if jq -n --slurpfile cur "$SETTINGS" --slurpfile user "$USER_CHANGED" ' + ($cur[0] * $user[0]) + | walk(if type == "object" then with_entries(select(.value != null)) else . end) + ' >"$tmp"; then + mv "$tmp" "$SETTINGS" + else + rm -f "$tmp" + fi +fi + +# Re-derive deltas by diffing final settings against the baseline. A differing +# value is recorded verbatim (user wins, round-trips); a recorded deletion (null) +# is carried forward only while the key is still absent live, so a later real +# value isn't stomped back to null. +_write_diff() { + [[ -f "$SETTINGS" && -f "$CLEAN_SNAPSHOT" ]] || return 0 + local tmp + tmp=$(mktemp) + if jq -n --slurpfile cur "$SETTINGS" --slurpfile clean "$CLEAN_SNAPSHOT" --slurpfile prev "$USER_CHANGED" ' + def diff($c): + . as $v | + if ($v | type) == "object" and ($c | type) == "object" then + (reduce ($v | keys_unsorted[]) as $k ({}; + if ($c | has($k)) then + ($v[$k] | diff($c[$k])) as $sub | + if $sub == "__SAME__" then . else . + {($k): $sub} end + else + . + {($k): $v[$k]} + end + )) as $r | + (reduce ($c | keys_unsorted[]) as $k ($r; + if ($v | has($k)) then . else . + {($k): null} end + )) as $r | + if ($r | length) == 0 then "__SAME__" else $r end + else + if $v == $c then "__SAME__" else $v end + end; + ($cur[0] | diff($clean[0]) | if . == "__SAME__" then {} else . end) as $new | + ($prev[0] // {}) as $old | + ($old | [paths(. == null)]) as $null_paths | + reduce $null_paths[] as $p ($new; + if ($cur[0] | getpath($p)) == null then setpath($p; null) else . end) + ' >"$tmp"; then + mv "$tmp" "$USER_CHANGED" + else + rm -f "$tmp" + fi +} +trap _write_diff EXIT + +status=0 +"$REAL_CLAUDE" "$@" || status=$? +exit "$status"