luciazero 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,9 @@ SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
11
  AGENTS_MD="${CODEX_DIR}/AGENTS.md"
12
12
  START='<!-- luciazero:start -->'
13
13
  END='<!-- luciazero:end -->'
14
+ # written inside the block, under the start marker, when the install had to add
15
+ # a final newline to the user's content to make room for that marker
16
+ ADDED_NL_MARK='<!-- luciazero:added-final-newline -->'
14
17
  MANAGED_DIR="${CODEX_DIR}/.luciazero-managed"
15
18
 
16
19
  catalog() { sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' "$1"; }
@@ -19,11 +22,18 @@ skill_inventory() {
19
22
  catalog "${SRC}/skills/aliases.txt"
20
23
  }
21
24
 
22
- # collision-proof backup path for $1 (two runs in the same second must not overwrite)
25
+ # A free backup name for $1. Two runs in the same second must not overwrite
26
+ # each other, and a name a symlink already holds is taken too: `-e` follows
27
+ # the name and answers false for a symlink whose target is missing, which
28
+ # would send the `cp` below straight through that symlink and out of the
29
+ # config directory. This is a check, not a reservation -- the name is still
30
+ # free to be taken between the test and the `cp` (roadmap R24). The
31
+ # uninstaller's settings backup reserves its name with `O_CREAT | O_EXCL`
32
+ # instead, which the shell has no portable equivalent for.
23
33
  bakpath() {
24
34
  B="$1.bak.$(date +%Y%m%d%H%M%S)"
25
35
  N=1
26
- while [ -e "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
36
+ while [ -e "${B}" ] || [ -L "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
27
37
  printf '%s' "${B}"
28
38
  }
29
39
 
@@ -32,16 +42,91 @@ same_tree() {
32
42
  && diff -qr "$1" "$2" >/dev/null 2>&1
33
43
  }
34
44
 
35
- tree_parents_safe() {
36
- [ ! -L "$(dirname "$1")" ] && [ ! -L "$(dirname "$2")" ]
45
+ # A symlink anywhere between the config dir and a path we are about to delete
46
+ # can redirect that delete outside the config dir, so every directory on the
47
+ # way down has to be a real one. The config dir itself may be a symlink: where
48
+ # it lives is the user's own choice.
49
+ parents_safe() {
50
+ PS_ROOT="${CODEX_DIR%/}"
51
+ PS_DIR="$(dirname "$1")"
52
+ while [ "${PS_DIR}" != "${PS_ROOT}" ] && [ "${PS_DIR}" != "/" ] && [ "${PS_DIR}" != "." ]; do
53
+ [ ! -L "${PS_DIR}" ] || return 1
54
+ PS_NEXT="$(dirname "${PS_DIR}")"
55
+ [ "${PS_NEXT}" != "${PS_DIR}" ] || break
56
+ PS_DIR="${PS_NEXT}"
57
+ done
58
+ return 0
59
+ }
60
+
61
+ # Does $1 end in a newline? A last line without one is content like any other,
62
+ # and `awk` cannot pass it through: print terminates every record it writes, so
63
+ # a rewrite that goes through awk hands such a file back one byte longer.
64
+ ends_with_newline() {
65
+ [ -s "$1" ] && [ -z "$(tail -c 1 "$1")" ]
66
+ }
67
+
68
+ # Write $1 with its marker block removed and every other byte kept, including a
69
+ # last line that carries no newline.
70
+ #
71
+ # The newline directly above the start marker is removed with the block when
72
+ # the block says the installer put it there. The install has to: a start marker
73
+ # only counts on a line of its own, so a file whose last line was unterminated
74
+ # needs one before the block can be appended. That newline is the installer's,
75
+ # not the user's, and nothing in the finished file distinguishes it from a
76
+ # newline the user typed -- so the installer records it, on the line under the
77
+ # start marker, where the markers are its provenance exactly as they are the
78
+ # blank line's. The record is honoured only while the block is still the last
79
+ # thing in the file, which is where the install put it; a user who has moved
80
+ # the block since has moved that newline into the middle of their own text,
81
+ # where it is no longer provably ours and stays.
82
+ strip_marker_block() {
83
+ if ends_with_newline "$1"; then SMB_SRC_NL=1; else SMB_SRC_NL=0; fi
84
+ awk -v s="${START}" -v e="${END}" -v mark="${ADDED_NL_MARK}" -v srcnl="${SMB_SRC_NL}" '
85
+ $0==s {inblock=1; head=1; blockend=NR; next}
86
+ $0==e {inblock=0; blockend=NR; next}
87
+ inblock {if (head && $0==mark) added=1; head=0; blockend=NR; next}
88
+ {n++; keep[n]=$0; lastkept=NR}
89
+ END {
90
+ chop = (added && n > 0 && blockend == NR)
91
+ for (i = 1; i <= n; i++) {
92
+ printf "%s", keep[i]
93
+ if (i < n) printf "\n"
94
+ }
95
+ if (n > 0 && !chop && !(lastkept == NR && srcnl == 0)) printf "\n"
96
+ }
97
+ ' "$1"
98
+ }
99
+
100
+ # Exactly one well-formed marker pair, or none at all. Anything else — a start
101
+ # with no end, a second pair, a pair nested inside another — has no defined
102
+ # meaning, and the awk rewrites below would answer it by dropping whatever
103
+ # follows the opening marker. AGENTS.md is the user's file; an ambiguous one is
104
+ # left exactly as it is, down to the byte, rather than repaired by guesswork.
105
+ # Markers count only on a line of their own, which is what the rewrites match.
106
+ marker_block_ok() {
107
+ MB_FILE="$1"
108
+ [ -f "${MB_FILE}" ] || return 0
109
+ MB_S="$(grep -cxF "${START}" "${MB_FILE}" || true)"
110
+ MB_E="$(grep -cxF "${END}" "${MB_FILE}" || true)"
111
+ [ "${MB_S}" = 0 ] && [ "${MB_E}" = 0 ] && return 0
112
+ if [ "${MB_S}" = 1 ] && [ "${MB_E}" = 1 ]; then
113
+ MB_SL="$(grep -nxF "${START}" "${MB_FILE}" | cut -d: -f1)"
114
+ MB_EL="$(grep -nxF "${END}" "${MB_FILE}" | cut -d: -f1)"
115
+ [ "${MB_SL}" -lt "${MB_EL}" ] && return 0
116
+ fi
117
+ return 1
37
118
  }
38
119
 
39
120
  remove_managed_tree() {
40
121
  RT_DST="$1"; RT_SNAPSHOT="$2"; RT_SHIPPED="$3"; RT_LABEL="$4"; RT_ALLOW_SHIPPED="${5:-1}"
122
+ # ancestry first, and return: refusing further down would still leave the
123
+ # snapshot cleanup below to delete whatever the symlink points at
124
+ if ! parents_safe "${RT_DST}" || ! parents_safe "${RT_SNAPSHOT}"; then
125
+ echo " !! ${RT_LABEL} has a symlinked parent; left untouched" >&2
126
+ return 0
127
+ fi
41
128
  if [ ! -e "${RT_DST}" ] && [ ! -L "${RT_DST}" ]; then
42
129
  echo " ok ${RT_LABEL} (already absent)"
43
- elif ! tree_parents_safe "${RT_DST}" "${RT_SNAPSHOT}"; then
44
- echo " !! ${RT_LABEL} has a symlinked parent; left untouched" >&2
45
130
  elif same_tree "${RT_DST}" "${RT_SNAPSHOT}" \
46
131
  || { [ "${RT_ALLOW_SHIPPED}" = 1 ] && [ ! -e "${RT_SNAPSHOT}" ] && same_tree "${RT_DST}" "${RT_SHIPPED}"; }; then
47
132
  rm -rf "${RT_DST}"
@@ -67,7 +152,8 @@ remove_managed_tree "${CODEX_DIR}/skills/luciazero-bootstrap" \
67
152
  "skills/luciazero-bootstrap (retired alias)" 0
68
153
 
69
154
  AGENT_STAGE_ROOT="$(mktemp -d)"
70
- trap 'rm -rf "${AGENT_STAGE_ROOT}"' EXIT
155
+ AGENTS_TMP=""
156
+ trap 'rm -rf "${AGENT_STAGE_ROOT}"; [ -z "${AGENTS_TMP}" ] || rm -f "${AGENTS_TMP}"' EXIT
71
157
  while IFS= read -r AGENT_NAME; do
72
158
  AGENT_SOURCE="${AGENT_STAGE_ROOT}/${AGENT_NAME}"
73
159
  mkdir -p "${AGENT_SOURCE}"
@@ -89,15 +175,43 @@ if [ -f "${LEGACY_HANDOFF}/SKILL.md" ]; then
89
175
  fi
90
176
  fi
91
177
 
92
- if [ -f "${AGENTS_MD}" ] && grep -qF "${START}" "${AGENTS_MD}"; then
178
+ if [ -f "${AGENTS_MD}" ] && grep -qxF "${START}" "${AGENTS_MD}" && ! marker_block_ok "${AGENTS_MD}"; then
179
+ # the skills are gone by now, which is safe on its own; the file is not ours
180
+ # to interpret, so it keeps every byte it has, backup included
181
+ echo " !! AGENTS.md carries ambiguous Luciazero markers; left untouched" >&2
182
+ echo " expected exactly one '${START}' ... '${END}' pair, on their own lines" >&2
183
+ elif [ -f "${AGENTS_MD}" ] && grep -qxF "${START}" "${AGENTS_MD}"; then
93
184
  BACKUP="$(bakpath "${AGENTS_MD}")"
94
- cp "${AGENTS_MD}" "${BACKUP}"
95
- awk -v s="${START}" -v e="${END}" '
96
- $0==s {inblock=1; next}
97
- $0==e {inblock=0; next}
98
- !inblock {print}
99
- ' "${AGENTS_MD}" > "${AGENTS_MD}.tmp"
100
- mv "${AGENTS_MD}.tmp" "${AGENTS_MD}"
185
+ cp -p "${AGENTS_MD}" "${BACKUP}"
186
+ # The block goes and nothing else does, which is now the whole round trip
187
+ # rather than a compromise. `install-codex.sh` used to write a blank
188
+ # separator above the start marker, and removing only the block left it
189
+ # behind, so a file the user wrote grew one blank line per cycle. Removing
190
+ # it from here would have meant guessing whose that blank was -- the Claude
191
+ # side may drop its separator only because `install.sh` records that it
192
+ # added it and hashes the file it left, and there is no such record on this
193
+ # side. The install answered it instead: it appends its block without a
194
+ # separator and keeps the blank that spaces the doctrine inside the markers,
195
+ # where deleting the block deletes exactly what the install added. A block
196
+ # the user has since moved carries that blank with it, so a rearranged file
197
+ # comes back byte for byte too. The one byte the install cannot leave alone
198
+ # is a final newline on content that had none, because the start marker needs
199
+ # a line of its own; it records that newline inside the block, and
200
+ # `strip_marker_block` takes it away with the rest.
201
+ # `${AGENTS_MD}.tmp` is a name anyone with write access to the config dir can
202
+ # pre-create as a symlink, and both the rewrite and the rename would then
203
+ # follow it: the awk output lands wherever it points, and the symlink itself
204
+ # is published as AGENTS.md. mktemp picks a name nobody can predict and
205
+ # creates it exclusively; keeping it in the same directory keeps the rename
206
+ # on one filesystem, the way the Claude side already does it.
207
+ AGENTS_TMP="$(mktemp "${CODEX_DIR}/.luciazero-agents-md.XXXXXX")"
208
+ # mktemp makes its file 0600 and the rename publishes that file: copying the
209
+ # backup, which kept the original's mode, carries the mode onto it. The
210
+ # redirection below replaces the content and leaves the mode alone.
211
+ cp -p "${BACKUP}" "${AGENTS_TMP}"
212
+ strip_marker_block "${AGENTS_MD}" > "${AGENTS_TMP}"
213
+ mv "${AGENTS_TMP}" "${AGENTS_MD}"
214
+ AGENTS_TMP=""
101
215
  [ -s "${AGENTS_MD}" ] || rm -f "${AGENTS_MD}"
102
216
  echo " ok removed doctrine block (backup: $(basename "${BACKUP}"))"
103
217
  else
package/uninstall.sh CHANGED
@@ -11,6 +11,27 @@ SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
11
  DOCTRINE="luciazero.md"
12
12
  IMPORT_LINE="@${DOCTRINE}"
13
13
  GLOBAL_MD="${CLAUDE_DIR}/CLAUDE.md"
14
+ IMPORT_PROVENANCE="${CLAUDE_DIR}/.luciazero-import"
15
+
16
+ sha_of() {
17
+ if command -v shasum >/dev/null 2>&1; then shasum -a 256 < "$1" | cut -d' ' -f1
18
+ elif command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | cut -d' ' -f1
19
+ else echo ""; fi
20
+ }
21
+
22
+ # Read only a plain file of our own, proved by the marker install.sh writes as
23
+ # its first line. Following a symlink, or trusting any regular file that
24
+ # happens to sit at this path, would let somebody else's content decide
25
+ # whether a blank line in the user's CLAUDE.md gets deleted.
26
+ IMPORT_MARKER="luciazero-managed: import-provenance"
27
+ provenance_is_ours() {
28
+ [ -f "${IMPORT_PROVENANCE}" ] && [ ! -L "${IMPORT_PROVENANCE}" ] \
29
+ && [ "$(head -n 1 "${IMPORT_PROVENANCE}" 2>/dev/null)" = "${IMPORT_MARKER}" ]
30
+ }
31
+ read_provenance() {
32
+ provenance_is_ours || return 0
33
+ sed -n '2p' "${IMPORT_PROVENANCE}" 2>/dev/null || true
34
+ }
14
35
  MANAGED_DIR="${CLAUDE_DIR}/.luciazero-managed"
15
36
 
16
37
  catalog() { sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' "$1"; }
@@ -19,11 +40,18 @@ skill_inventory() {
19
40
  catalog "${SRC}/skills/aliases.txt"
20
41
  }
21
42
 
22
- # collision-proof backup path for $1 (two runs in the same second must not overwrite)
43
+ # A free backup name for $1. Two runs in the same second must not overwrite
44
+ # each other, and a name a symlink already holds is taken too: `-e` follows
45
+ # the name and answers false for a symlink whose target is missing, which
46
+ # would send the `cp` below straight through that symlink and out of the
47
+ # config directory. This is a check, not a reservation -- the name is still
48
+ # free to be taken between the test and the `cp` (roadmap R24). The
49
+ # uninstaller's settings backup reserves its name with `O_CREAT | O_EXCL`
50
+ # instead, which the shell has no portable equivalent for.
23
51
  bakpath() {
24
52
  B="$1.bak.$(date +%Y%m%d%H%M%S)"
25
53
  N=1
26
- while [ -e "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
54
+ while [ -e "${B}" ] || [ -L "${B}" ]; do B="$1.bak.$(date +%Y%m%d%H%M%S).${N}"; N=$((N+1)); done
27
55
  printf '%s' "${B}"
28
56
  }
29
57
 
@@ -32,16 +60,32 @@ same_tree() {
32
60
  && diff -qr "$1" "$2" >/dev/null 2>&1
33
61
  }
34
62
 
35
- tree_parents_safe() {
36
- [ ! -L "$(dirname "$1")" ] && [ ! -L "$(dirname "$2")" ]
63
+ # A symlink anywhere between the config dir and a path we are about to delete
64
+ # can redirect that delete outside the config dir, so every directory on the
65
+ # way down has to be a real one. The config dir itself may be a symlink: where
66
+ # it lives is the user's own choice.
67
+ parents_safe() {
68
+ PS_ROOT="${CLAUDE_DIR%/}"
69
+ PS_DIR="$(dirname "$1")"
70
+ while [ "${PS_DIR}" != "${PS_ROOT}" ] && [ "${PS_DIR}" != "/" ] && [ "${PS_DIR}" != "." ]; do
71
+ [ ! -L "${PS_DIR}" ] || return 1
72
+ PS_NEXT="$(dirname "${PS_DIR}")"
73
+ [ "${PS_NEXT}" != "${PS_DIR}" ] || break
74
+ PS_DIR="${PS_NEXT}"
75
+ done
76
+ return 0
37
77
  }
38
78
 
39
79
  remove_managed_tree() {
40
80
  RT_DST="$1"; RT_SNAPSHOT="$2"; RT_SHIPPED="$3"; RT_LABEL="$4"; RT_ALLOW_SHIPPED="${5:-1}"
81
+ # ancestry first, and return: refusing further down would still leave the
82
+ # snapshot cleanup below to delete whatever the symlink points at
83
+ if ! parents_safe "${RT_DST}" || ! parents_safe "${RT_SNAPSHOT}"; then
84
+ echo " !! ${RT_LABEL} has a symlinked parent; left untouched" >&2
85
+ return 0
86
+ fi
41
87
  if [ ! -e "${RT_DST}" ] && [ ! -L "${RT_DST}" ]; then
42
88
  echo " ok ${RT_LABEL} (already absent)"
43
- elif ! tree_parents_safe "${RT_DST}" "${RT_SNAPSHOT}"; then
44
- echo " !! ${RT_LABEL} has a symlinked parent; left untouched" >&2
45
89
  elif same_tree "${RT_DST}" "${RT_SNAPSHOT}" \
46
90
  || { [ "${RT_ALLOW_SHIPPED}" = 1 ] && [ ! -e "${RT_SNAPSHOT}" ] && same_tree "${RT_DST}" "${RT_SHIPPED}"; }; then
47
91
  rm -rf "${RT_DST}"
@@ -54,6 +98,10 @@ remove_managed_tree() {
54
98
 
55
99
  remove_managed_file() {
56
100
  RF_DST="$1"; RF_SNAPSHOT="$2"; RF_SHIPPED="$3"; RF_LABEL="$4"
101
+ if ! parents_safe "${RF_DST}" || ! parents_safe "${RF_SNAPSHOT}"; then
102
+ echo " !! ${RF_LABEL} has a symlinked parent; left untouched" >&2
103
+ return 0
104
+ fi
57
105
  if [ ! -e "${RF_DST}" ] && [ ! -L "${RF_DST}" ]; then
58
106
  echo " ok ${RF_LABEL} (already absent)"
59
107
  elif [ -f "${RF_DST}" ] && [ ! -L "${RF_DST}" ] \
@@ -92,6 +140,66 @@ done < <(catalog "${SRC}/claude/agents/catalog.txt")
92
140
 
93
141
  rmdir "${MANAGED_DIR}/skills" "${MANAGED_DIR}/agents" "${MANAGED_DIR}" 2>/dev/null || true
94
142
 
143
+ # Agent Bus launcher. Only a regular file carrying the ownership marker is
144
+ # ours to delete: a symlink is something the user made, and anything without
145
+ # the marker is another program that happens to share the name.
146
+ AGENTD_MARKER="luciazero-managed: agentd-launcher"
147
+ AGENTD_SERVICE_MARKER="luciazero-managed: agentd-service"
148
+ AGENTD_BIN_DIR="${LUCIAZERO_BIN_DIR:-${CLAUDE_DIR}/bin}"
149
+ AGENTD_LAUNCHER="${AGENTD_BIN_DIR}/luciazero-agentd"
150
+ # Both names install.sh writes. The long one is kept in its own variable
151
+ # because the service is stopped through it before either is removed.
152
+ AGENTD_NAMES="luciazero-agentd lucia"
153
+
154
+ # The background service outlives this script unless it is stopped first.
155
+ # Removing the launcher while a LaunchAgent or a systemd unit still points at
156
+ # it leaves either a daemon serving after an uninstall or a service manager
157
+ # restarting a file that is gone, so the service is dealt with first and the
158
+ # launcher stays put if it could not be.
159
+ AGENTD_KEEP=0
160
+ AGENTD_SERVICE_ROOT="${LUCIAZERO_SERVICE_ROOT:-$HOME}"
161
+ for AGENTD_SVC in "${AGENTD_SERVICE_ROOT}/Library/LaunchAgents/com.luciazero.agentd.plist" \
162
+ "${AGENTD_SERVICE_ROOT}/.config/systemd/user/luciazero-agentd.service"; do
163
+ [ -f "${AGENTD_SVC}" ] || continue
164
+ grep -qF "${AGENTD_SERVICE_MARKER}" "${AGENTD_SVC}" 2>/dev/null || continue
165
+ AGENTD_RUN=""
166
+ if [ -f "${AGENTD_LAUNCHER}" ] && grep -qF "${AGENTD_MARKER}" "${AGENTD_LAUNCHER}" 2>/dev/null; then
167
+ AGENTD_RUN="${AGENTD_LAUNCHER}"
168
+ fi
169
+ if [ -n "${AGENTD_RUN}" ] && "${AGENTD_RUN}" service uninstall >/dev/null 2>&1; then
170
+ echo " ok agent bus service stopped and removed"
171
+ elif [ -d "${SRC}/agentd/luciazero_agentd" ] && command -v python3 >/dev/null 2>&1 \
172
+ && PYTHONPATH="${SRC}/agentd" python3 -m luciazero_agentd service uninstall >/dev/null 2>&1; then
173
+ echo " ok agent bus service stopped and removed"
174
+ else
175
+ echo " !! the Agent Bus service is still installed (${AGENTD_SVC})" >&2
176
+ echo " stop it first: luciazero-agentd service uninstall" >&2
177
+ echo " the launcher is left in place so the service does not restart a missing file" >&2
178
+ AGENTD_KEEP=1
179
+ fi
180
+ done
181
+
182
+ if [ "${AGENTD_KEEP}" = 0 ]; then
183
+ for AGENTD_NAME in ${AGENTD_NAMES}; do
184
+ AGENTD_TARGET="${AGENTD_BIN_DIR}/${AGENTD_NAME}"
185
+ if [ -L "${AGENTD_TARGET}" ]; then
186
+ echo " !! ${AGENTD_TARGET} is a symlink you made; left untouched" >&2
187
+ elif [ -f "${AGENTD_TARGET}" ]; then
188
+ if grep -qF "${AGENTD_MARKER}" "${AGENTD_TARGET}" 2>/dev/null; then
189
+ rm -f "${AGENTD_TARGET}"
190
+ echo " ok bin/${AGENTD_NAME}"
191
+ else
192
+ echo " !! ${AGENTD_TARGET} is not the Luciazero launcher; left untouched" >&2
193
+ fi
194
+ elif [ -e "${AGENTD_TARGET}" ]; then
195
+ echo " !! ${AGENTD_TARGET} is not a regular file; left untouched" >&2
196
+ fi
197
+ done
198
+ # Only once both are gone, and only if it is empty.
199
+ rmdir "${AGENTD_BIN_DIR}" 2>/dev/null || true
200
+ rm -f "${CLAUDE_DIR}/.luciazero-agentd-home"
201
+ fi
202
+
95
203
  LEGACY_HANDOFF="${CLAUDE_DIR}/skills/handoff"
96
204
  if [ -f "${LEGACY_HANDOFF}/SKILL.md" ]; then
97
205
  if cmp -s "${SRC}/migrations/handoff-v1.5.0.SKILL.md" "${LEGACY_HANDOFF}/SKILL.md"; then
@@ -108,24 +216,61 @@ fi
108
216
  # files we just deleted.
109
217
  SETTINGS="${CLAUDE_DIR}/settings.json"
110
218
  HOOKS_CLEAN=1
111
- if [ -f "${SETTINGS}" ] && grep -qF "${CLAUDE_DIR}/hooks/luciazero-" "${SETTINGS}"; then
219
+ # No `grep` gate. The installer quotes the path it writes, so a config
220
+ # directory whose name contains an apostrophe is stored as
221
+ # `'/home/config with '"'"' quote/hooks/luciazero-verify.sh' edit` -- the bare
222
+ # path is not a substring of that, and a grep for it answered "nothing of ours
223
+ # here" while the hook files were deleted anyway, leaving settings.json
224
+ # pointing at files that no longer exist. Only the parser knows what is ours,
225
+ # so the parser is asked whenever there is a file to ask about. It reports
226
+ # three separate outcomes and never writes a backup it did not need:
227
+ # 0 nothing of ours -- settings.json untouched
228
+ # 10 ours found and removed -- backup written first
229
+ # * read, parse or write failed -- settings.json is left exactly as it was
230
+ if [ -f "${SETTINGS}" ]; then
112
231
  if command -v python3 >/dev/null 2>&1; then
113
- cp "${SETTINGS}" "$(bakpath "${SETTINGS}")"
232
+ HOOKS_RC=0
114
233
  # exact-path matching only: never touch a user's own hook that merely
115
234
  # shares a basename with ours
116
- if python3 - "${SETTINGS}" "${CLAUDE_DIR}" 2>/dev/null <<'PY'
117
- import json, os, sys
235
+ python3 - "${SETTINGS}" "${CLAUDE_DIR}" <<'PY' || HOOKS_RC=$?
236
+ import json, os, shlex, shutil, sys, time
118
237
 
119
238
  path, claude_dir = sys.argv[1], sys.argv[2]
120
- with open(path) as f:
121
- settings = json.load(f)
239
+ try:
240
+ with open(path) as f:
241
+ settings = json.load(f)
242
+ except (OSError, ValueError) as exc:
243
+ print(" " + str(exc), file=sys.stderr)
244
+ raise SystemExit(1)
245
+
246
+ # settings.json is the user's file and may hold any JSON at all. A shape this
247
+ # cannot walk is not "nothing of ours": it is a file we cannot prove clean, so
248
+ # it stays as it is and the hook files stay with it.
249
+ if not isinstance(settings, dict) or not isinstance(settings.get("hooks", {}), dict):
250
+ print(" settings.json is valid JSON but not the shape hooks live in",
251
+ file=sys.stderr)
252
+ raise SystemExit(1)
122
253
 
123
254
  MARKERS = (
124
255
  os.path.join(claude_dir, "hooks", "luciazero-verify.sh"),
125
256
  os.path.join(claude_dir, "hooks", "luciazero-statusline.sh"),
126
257
  )
127
258
  def ours(cmd):
128
- return any(cmd == m or cmd.startswith(m + " ") for m in MARKERS)
259
+ """Both spellings of our own command, and nothing else.
260
+
261
+ The installer quotes the path now, so the bytes no longer start with it;
262
+ matching only the bare prefix would leave every entry it wrote behind,
263
+ still pointing at files this script is about to delete. The bare form
264
+ stays recognised because older installs wrote it -- including the broken
265
+ bare form with a space in it, which `shlex` cannot parse back.
266
+ """
267
+ if any(cmd == m or cmd.startswith(m + " ") for m in MARKERS):
268
+ return True
269
+ try:
270
+ parts = shlex.split(cmd)
271
+ except ValueError:
272
+ return False
273
+ return bool(parts) and parts[0] in MARKERS
129
274
 
130
275
  changed = False
131
276
  hooks = settings.get("hooks") or {}
@@ -153,23 +298,85 @@ if isinstance(sl, dict) and ours(sl.get("command", "")):
153
298
  del settings["statusLine"]
154
299
  changed = True
155
300
 
156
- if changed:
301
+ if not changed:
302
+ raise SystemExit(0)
303
+
304
+ # The user's file changes only once the new content is known, and only after a
305
+ # complete copy of the old one exists beside it, under a name nothing else
306
+ # holds.
307
+ #
308
+ # "Nothing else holds" cannot be asked with os.path.exists: it follows the
309
+ # name, and answers False for a symlink whose target is missing. A dangling
310
+ # symlink planted at the name this was about to take therefore read as free,
311
+ # and the copy then followed it -- writing the user's settings outside the
312
+ # config directory, under a name the planter chose, while the config directory
313
+ # was left with no backup at all. O_CREAT|O_EXCL is the question that cannot
314
+ # be fooled: the kernel refuses the open if anything is at the name, a
315
+ # dangling symlink included, and refuses without following it. The name is
316
+ # ours only once that open has returned, so the bytes go through that
317
+ # descriptor and the name is never resolved a second time.
318
+ stamp = time.strftime("%Y%m%d%H%M%S")
319
+ backup, fd, n = path + ".bak." + stamp, None, 1
320
+ while fd is None:
321
+ try:
322
+ fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
323
+ except FileExistsError:
324
+ if n > 100:
325
+ print(" no free backup name beside " + os.path.basename(path),
326
+ file=sys.stderr)
327
+ raise SystemExit(1)
328
+ backup = path + ".bak." + stamp + "." + str(n)
329
+ n += 1
330
+ except OSError as exc:
331
+ print(" " + str(exc), file=sys.stderr)
332
+ raise SystemExit(1)
333
+
334
+ # A backup that is not complete is not a backup. If any part of making one
335
+ # fails, the reserved file goes and settings.json is never opened for writing
336
+ # -- which is the outcome that keeps the hook files, upstairs.
337
+ try:
338
+ with open(path, "rb") as src, os.fdopen(fd, "wb") as dst:
339
+ fd = None # fdopen owns it now, and closing it twice is an error
340
+ shutil.copyfileobj(src, dst)
341
+ shutil.copystat(path, backup) # mode and times, the half copy2 adds
342
+ except OSError as exc:
343
+ if fd is not None:
344
+ os.close(fd)
345
+ try:
346
+ os.unlink(backup)
347
+ except OSError:
348
+ pass
349
+ print(" " + str(exc), file=sys.stderr)
350
+ raise SystemExit(1)
351
+
352
+ try:
157
353
  with open(path, "w") as f:
158
354
  json.dump(settings, f, indent=2, ensure_ascii=False)
159
355
  f.write("\n")
356
+ except OSError as exc:
357
+ print(" " + str(exc), file=sys.stderr)
358
+ raise SystemExit(1)
359
+ print(" ok backup: " + os.path.basename(backup))
360
+ raise SystemExit(10)
160
361
  PY
161
- then
162
- echo " ok removed hook entries from settings.json"
163
- else
164
- HOOKS_CLEAN=0
165
- echo " !! could not clean settings.json (invalid JSON?) — hook files kept so nothing dangles; remove the luciazero-* entries manually, then delete ${CLAUDE_DIR}/hooks/luciazero-*.sh" >&2
166
- fi
362
+ case "${HOOKS_RC}" in
363
+ 0)
364
+ echo " ok no enforcement-pack entries in settings.json"
365
+ ;;
366
+ 10)
367
+ echo " ok removed hook entries from settings.json"
368
+ ;;
369
+ *)
370
+ HOOKS_CLEAN=0
371
+ echo " !! could not clean settings.json (invalid JSON?) — hook files kept so nothing dangles; remove the luciazero-* entries manually, then delete ${CLAUDE_DIR}/hooks/luciazero-*.sh" >&2
372
+ ;;
373
+ esac
167
374
  else
168
375
  HOOKS_CLEAN=0
169
376
  echo " !! python3 not found — settings.json untouched; hook files kept so nothing dangles" >&2
170
377
  fi
171
378
  else
172
- echo " ok no enforcement-pack entries in settings.json"
379
+ echo " ok no settings.json to clean"
173
380
  fi
174
381
  if [ "${HOOKS_CLEAN}" = 1 ]; then
175
382
  for H in luciazero-verify.sh luciazero-statusline.sh; do
@@ -187,15 +394,82 @@ fi
187
394
 
188
395
  if [ -f "${GLOBAL_MD}" ] && grep -qF "${IMPORT_LINE}" "${GLOBAL_MD}"; then
189
396
  BACKUP="$(bakpath "${GLOBAL_MD}")"
190
- cp "${GLOBAL_MD}" "${BACKUP}"
191
- # grep exits 1 when the import line was the only content — that is fine
192
- grep -vxF "${IMPORT_LINE}" "${GLOBAL_MD}" > "${GLOBAL_MD}.tmp" || [ $? -eq 1 ]
193
- mv "${GLOBAL_MD}.tmp" "${GLOBAL_MD}"
194
- [ -s "${GLOBAL_MD}" ] || rm -f "${GLOBAL_MD}"
195
- echo " ok removed import line (backup: $(basename "${BACKUP}"))"
397
+ cp -p "${GLOBAL_MD}" "${BACKUP}"
398
+ # `install.sh` appends the import line to an existing CLAUDE.md as
399
+ # `printf '\n%s\n'` — a blank separator and then the line — so removing only
400
+ # the line leaves the separator behind and every install-and-uninstall cycle
401
+ # grows a file the user wrote. Removing it is only safe where this
402
+ # installer is provably the one that put it there: the same file shape
403
+ # arises when somebody writes the import line themselves, and `install.sh`
404
+ # then leaves the file untouched, which makes the blank theirs. So the
405
+ # separator goes only on the record `install.sh` left behind, and the record
406
+ # carries the hash of the file as the installer left it, so it says something
407
+ # about THIS file and not merely about what the installer usually does. Move
408
+ # the import line, add a blank line, change a word: the hash stops matching
409
+ # and the separator stays. Any other value -- including none, which is every
410
+ # install older than this one, and every install that found a foreign
411
+ # `.luciazero-import` and refused to write -- takes the conservative path of
412
+ # removing the line and nothing else.
413
+ # BACKUP is the snapshot everything below reads: the hash is taken from it
414
+ # and the rewrite is computed from it, so the decision and the transform
415
+ # cannot see two different files. The live file is compared against that
416
+ # snapshot again before the result is published; anything that changed it in
417
+ # between wins, and this leaves it alone. A rename cannot be made atomic
418
+ # against an editor that is mid-write, so this narrows the window rather
419
+ # than closing it.
420
+ PROV="$(read_provenance)"
421
+ MD_TMP="$(mktemp "${CLAUDE_DIR}/.luciazero-claude-md.XXXXXX")"
422
+ # mktemp makes its file 0600 and the rename below publishes that file, so
423
+ # without this a 0640 CLAUDE.md came back 0600. Copying the backup -- which
424
+ # kept the original's mode -- onto the temporary file carries the mode over;
425
+ # the redirection that follows replaces the content and leaves it.
426
+ cp -p "${BACKUP}" "${MD_TMP}"
427
+ if [ "${PROV%% *}" = appended ] && [ -n "${PROV#appended }" ] \
428
+ && [ "${PROV#appended }" = "$(sha_of "${BACKUP}")" ]; then
429
+ awk -v want="${IMPORT_LINE}" '
430
+ $0 == want { pending = 0; next }
431
+ pending { print ""; pending = 0 }
432
+ $0 == "" { pending = 1; next }
433
+ { print }
434
+ END { if (pending) print "" }
435
+ ' "${BACKUP}" > "${MD_TMP}"
436
+ else
437
+ # grep exits 1 when the import line was the only content — that is fine
438
+ grep -vxF "${IMPORT_LINE}" "${BACKUP}" > "${MD_TMP}" || [ $? -eq 1 ]
439
+ fi
440
+ if cmp -s "${BACKUP}" "${GLOBAL_MD}"; then
441
+ mv "${MD_TMP}" "${GLOBAL_MD}"
442
+ [ -s "${GLOBAL_MD}" ] || rm -f "${GLOBAL_MD}"
443
+ IMPORT_REWRITTEN=1
444
+ else
445
+ rm -f "${MD_TMP}"
446
+ IMPORT_REWRITTEN=0
447
+ echo " !! CLAUDE.md changed while this was running; left exactly as it is now (backup: $(basename "${BACKUP}"))" >&2
448
+ fi
449
+ # A backup whose entire content is the line this installer wrote, on a
450
+ # CLAUDE.md that held nothing else, protects nothing: the machine had no
451
+ # CLAUDE.md before the install, and leaving the backup means it does not
452
+ # come back to that. Only this invocation's own BACKUP path is considered --
453
+ # never a glob, and never an older backup, whose identical content would
454
+ # still be somebody's decision to keep. One byte of anyone else's text and
455
+ # the file stays.
456
+ if [ "${IMPORT_REWRITTEN}" = 1 ]; then
457
+ if printf '%s\n' "${IMPORT_LINE}" | cmp -s - "${BACKUP}"; then
458
+ rm -f "${BACKUP}"
459
+ echo " ok removed import line (its backup held only that line; removed)"
460
+ else
461
+ echo " ok removed import line (backup: $(basename "${BACKUP}"))"
462
+ fi
463
+ fi
196
464
  else
197
465
  echo " ok no import line in CLAUDE.md"
198
466
  fi
467
+ # Ours to remove only if it is ours, by the same marker install.sh wrote.
468
+ # Anything else at this path -- a symlink, a directory, a regular file with
469
+ # somebody's notes in it -- is left exactly where it is.
470
+ if provenance_is_ours; then
471
+ rm -f "${IMPORT_PROVENANCE}"
472
+ fi
199
473
 
200
474
  for KEEP in luciazero-stats.log luciazero-heuristics.md; do
201
475
  if [ -f "${CLAUDE_DIR}/${KEEP}" ]; then
@@ -206,5 +480,12 @@ if [ -d "${CLAUDE_DIR}/.luciazero-backups" ]; then
206
480
  echo " kept .luciazero-backups/ (pre-existing or customized components) — review and delete manually when no longer needed"
207
481
  fi
208
482
 
483
+ # Directories this installer is the reason for, leaf to root, by name and
484
+ # never by glob. rmdir refuses a directory that still holds anything, so one
485
+ # file of the user's own keeps its directory and everything above it.
486
+ rmdir "${CLAUDE_DIR}/skills" "${CLAUDE_DIR}/agents" 2>/dev/null || true
487
+ rmdir "${CLAUDE_DIR}" 2>/dev/null || true
488
+
209
489
  echo
210
490
  echo "Done. Other CLAUDE.md content was left untouched."
491
+ echo "The Agent Bus state directory (~/.luciazero/agent-bus) is data and was not touched."