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.
- package/README.md +140 -20
- package/bin/bus.js +121 -0
- package/bin/luciazero-agentd +107 -0
- package/bin/luciazero.js +3 -1
- package/install-codex.sh +104 -12
- package/install.sh +283 -25
- package/package.json +10 -3
- package/skills/catalog.txt +2 -0
- package/skills/done/SKILL.md +6 -3
- package/skills/done/scripts/revert-probe.sh +137 -26
- package/skills/imouto-mode/SKILL.md +15 -1
- package/skills/lucia-bus/SKILL.md +75 -0
- package/skills/lucia-chat/SKILL.md +152 -0
- package/skills/lucia-relay/SKILL.md +5 -5
- package/uninstall-codex.sh +129 -15
- package/uninstall.sh +308 -27
package/uninstall-codex.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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."
|