Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .chezmoiignore.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
42 changes: 42 additions & 0 deletions dot_local/share/overrides/bin/executable_aifx
Original file line number Diff line number Diff line change
@@ -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" "$@"
79 changes: 79 additions & 0 deletions dot_local/share/overrides/bin/executable_claude
Original file line number Diff line number Diff line change
@@ -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"
Loading