Repository navigation
Expand file tree
/
Copy pathuninstall.sh
More file actions
executable file
·872 lines (821 loc) · 37.9 KB
/
Copy pathuninstall.sh
File metadata and controls
executable file
·872 lines (821 loc) · 37.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
#!/usr/bin/env bash
set -euo pipefail
# agmsg — Agent Messaging uninstaller
# With no options, removes ONE install's own messaging skill, commands, and
# hooks (#1400: this used to remove every agmsg install found on the
# machine, unconditionally). --all restores that "remove everything" shape,
# but only when explicitly asked for.
#
# Usage:
# ./uninstall.sh # This install only (confirms each step)
# ./uninstall.sh --yes # This install only, no confirmation
# ./uninstall.sh --keep-data # Remove skill but keep DB and teams
# ./uninstall.sh --all # Every agmsg install on the machine
# # (one combined confirmation, unless --yes)
# Mirrors install.sh's own AGMSG_INSTALL_HOME: relocates the command/skill
# destinations this script removes from (see install.sh's own top-of-file
# comment for the full list and what is deliberately excluded -- CLAUDE_CONFIG_DIR,
# CODEX_HOME, and the install's runtime state root under AGMSG_HOME/~/.agmsg,
# which this does not move). Unset, this is $HOME, byte-for-byte the prior
# behavior for those destinations.
INSTALL_HOME="${AGMSG_INSTALL_HOME:-$HOME}"
# How the messages below name a location under INSTALL_HOME: "~" when it is the
# default (the text these messages always had), the real path once
# AGMSG_INSTALL_HOME relocates it.
INSTALL_HOME_SHOWN="${AGMSG_INSTALL_HOME:-~}"
AGENTS_DIR="$INSTALL_HOME/.agents"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AGMSG_INSTALL_OP_RECOVERY_SOURCE=""
if [ -e "$SCRIPT_DIR/run/install-op-incomplete.json" ] || [ -L "$SCRIPT_DIR/run/install-op-incomplete.json" ]; then
if [ -r "$SCRIPT_DIR/run/install-op-recovery.sh" ]; then
AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh"
else
for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do
[ -r "$_agmsg_recovery_candidate" ] || continue
AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate"
break
done
unset _agmsg_recovery_candidate
fi
fi
if [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then
# Prefer the atomically-published recovery code while an operation record
# exists; scripts/ may be incomplete after an interrupted update.
# shellcheck disable=SC1090
. "$AGMSG_INSTALL_OP_RECOVERY_SOURCE"
elif [ -r "$SCRIPT_DIR/scripts/lib/codex-config.sh" ] && [ -r "$SCRIPT_DIR/scripts/lib/install-op-lock.sh" ]; then
# shellcheck disable=SC1091
. "$SCRIPT_DIR/scripts/lib/codex-config.sh"
# The same operation lock install.sh takes (agmsgd beta).
# shellcheck disable=SC1091
. "$SCRIPT_DIR/scripts/lib/install-op-lock.sh"
else
AGMSG_INSTALL_OP_RECOVERY_SOURCE="$SCRIPT_DIR/run/install-op-recovery.sh"
if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then
for _agmsg_recovery_candidate in "$SCRIPT_DIR"/run/.install-op-recovery-retired.*; do
[ -r "$_agmsg_recovery_candidate" ] || continue
AGMSG_INSTALL_OP_RECOVERY_SOURCE="$_agmsg_recovery_candidate"
break
done
unset _agmsg_recovery_candidate
fi
if [ ! -r "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then
echo " ! uninstall support files are missing; restore the install before continuing" >&2
exit 1
fi
# This fallback remains under run/ while an uninstall is incomplete, so an
# installed copy can recover after --keep-data removed scripts/.
# shellcheck disable=SC1090
. "$AGMSG_INSTALL_OP_RECOVERY_SOURCE"
fi
# The state-root helpers (an older recovery helper does not carry them).
if ! declare -F agmsg_state_detach_foreign >/dev/null 2>&1 && [ -r "$SCRIPT_DIR/scripts/lib/state-root.sh" ]; then
# shellcheck disable=SC1091
. "$SCRIPT_DIR/scripts/lib/state-root.sh"
fi
AUTO_YES=false
KEEP_DATA=false
REMOVE_ALL=false
CMD_NAME=""
RECOVER_ID=""
AGMSG_INSTALL_OP_ACTIVE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--yes|-y) AUTO_YES=true; shift ;;
--keep-data) KEEP_DATA=true; shift ;;
--all) REMOVE_ALL=true; shift ;;
--cmd) CMD_NAME="$2"; shift 2 ;;
--recover) RECOVER_ID="$2"; shift 2 ;;
-h|--help)
echo "Usage: ./uninstall.sh [options]"
echo ""
echo "Options:"
echo " --yes, -y Remove without confirmation"
echo " --keep-data Remove skill but keep DB and team configs"
echo " --all Remove every agmsg install on the machine"
echo " --cmd <name> Select one installation under $INSTALL_HOME_SHOWN/.agents/skills"
echo " --recover <id> Clear a verified incomplete operation, then continue uninstall"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [ -n "$RECOVER_ID" ] && { [ -z "$CMD_NAME" ] || [ "$REMOVE_ALL" = true ]; }; then
echo " ! --recover requires --cmd <name> and cannot be combined with --all" >&2
exit 1
fi
echo ""
echo " agmsg — Uninstall"
echo " ──────────────────"
echo ""
confirm() {
if [ "$AUTO_YES" = true ]; then return 0; fi
printf " %s (y/n) [n]: " "$1"
read -r input
[ "${input:-n}" = "y" ] || [ "${input:-n}" = "Y" ]
}
REMOVED=false
_uninstall_operation_exit() {
if [ "${AGMSG_INSTALL_OP_ACTIVE:-false}" = true ]; then
agmsg_install_op_unlock
AGMSG_INSTALL_OP_ACTIVE=false
fi
}
trap '_uninstall_operation_exit' EXIT
trap 'agmsg_install_op_handle_signal INT' INT
trap 'agmsg_install_op_handle_signal TERM' TERM
_uninstall_operation_require() {
if agmsg_install_op_require; then
return 0
fi
agmsg_install_op_unlock
return 1
}
_uninstall_checked_rm() {
_uninstall_operation_require || return 1
if ! agmsg_install_op_run_writer rm "$@"; then
agmsg_install_op_unlock
return 1
fi
_uninstall_operation_require
}
# Removes this install's own writable_roots from the selected Codex config,
# matching the native path spelling used by installation on Windows. Each
# argument after the config is a directory: an element that is that directory
# or lies under it goes (never a sibling install whose path only starts the
# same way).
#
# The file is read by the same whole-file scanner install --update uses
# (agmsg_codex_roots_scan in scripts/lib/codex-config.sh), so only the real
# writable_roots array is looked at: the same text inside a multi-line value
# elsewhere (an example in developer_instructions, say) stays byte for byte.
# Only this install's elements are taken out: an array that is left empty, and
# the table around it, stay as they are (an empty list is harmless to Codex),
# so nothing of anyone else's is ever judged or removed.
# It is transformed into a candidate file first, and the config is backed up
# and replaced only when that changes it: a config that only mentions a
# SIBLING install's root is never touched, backed up, or reported "cleaned".
_uninstall_clean_codex_config() {
local CODEX_CONFIG="$1" scan=0 prefixes="" _dir
shift
[ -f "$CODEX_CONFIG" ] || return 0
for _dir in "$@"; do
prefixes="${prefixes}$(agmsg_codex_native_path "$_dir")"$'\n'
done
AGMSG_STALE_PREFIXES="$prefixes" \
agmsg_codex_roots_scan "$CODEX_CONFIG" > "$CODEX_CONFIG.tmp" || scan=$?
if [ "$scan" -eq 10 ]; then
rm -f "$CODEX_CONFIG.tmp"
return 0
fi
if [ "$scan" -lt 11 ] || [ "$scan" -gt 13 ]; then
rm -f "$CODEX_CONFIG.tmp"
return 1
fi
_uninstall_operation_require || return 1
cp "$CODEX_CONFIG" "$CODEX_CONFIG.bak"
_uninstall_operation_require || return 1
mv "$CODEX_CONFIG.tmp" "$CODEX_CONFIG"
_uninstall_operation_require || return 1
echo " - cleaned Codex writable_roots in $CODEX_CONFIG (backup: $(basename "$CODEX_CONFIG").bak)"
REMOVED=true
}
# Removes ONE install's own commands, hooks, skill files, and Codex
# writable_roots entries -- everything except the machine-wide shared
# pieces, which the caller handles once, separately (_uninstall_shared_pieces
# below). Sets the global REMOVED=true on any change.
_uninstall_one() {
local SKILL_DIR="$1"
# A copy of an install points at the ORIGINAL's state: detach it before any lock or record.
agmsg_state_detach_foreign "$SKILL_DIR" || return 1
local SKILL_NAME; SKILL_NAME="$(basename "$SKILL_DIR")"
# Operation lock (agmsgd beta): held for the whole of this function,
# the same lock install.sh takes for the same
# SKILL_DIR. A failure here means another install/uninstall/enable/
# disable is already in progress against this exact install -- refuse
# rather than race it. No trap releases this on an unexpected abort: the
# OS drops the file lock the moment this process dies; there is no
# stale-lock recovery step, so an uninstall
# that crashes mid-way is the same "held until the holder dies" state a
# crashed install.sh already leaves.
if ! agmsg_install_op_lock "$SKILL_DIR/.install-op/lock.db"; then
echo " ! could not take the install operation lock for $SKILL_DIR: ${AGMSG_INSTALL_OP_LOCK_FAILURE_REASON:-unknown lock handshake failure}" >&2
return 1
fi
AGMSG_INSTALL_OP_ACTIVE=true
if ! agmsg_install_op_legacy_lock_hold "$SKILL_DIR"; then
echo " ! another agmsg install or update is running (it holds the old-layout lock $SKILL_DIR/run/install-op.lock.db); nothing was changed. Run this again after it finishes." >&2
return 1
fi
_uninstall_operation_require || return 1
local pending="$SKILL_DIR/run/install-op-incomplete.json"
local recovery_prefix recovery_entrypoint="$SKILL_DIR/uninstall.sh" running_entrypoint="" operation_mode=remove-data recovery_helper="$SKILL_DIR/run/install-op-recovery.sh" recovery_retired="" uninstaller_retired=""
if [ "$SCRIPT_DIR" = "$SKILL_DIR" ]; then
case "$(basename "$0")" in
.install-op-uninstaller-retired.*) running_entrypoint="$SKILL_DIR/$(basename "$0")" ;;
esac
fi
if [ ! -f "$recovery_entrypoint" ]; then
if [ -f "$pending" ] && agmsg_install_op_pending_validate "$pending"; then
local _recovery_entrypoint_candidate="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_PENDING_ID"
[ -f "$_recovery_entrypoint_candidate" ] && recovery_entrypoint="$_recovery_entrypoint_candidate"
unset _recovery_entrypoint_candidate
fi
fi
[ "$KEEP_DATA" = true ] && operation_mode=keep-data
recovery_prefix="bash $(printf '%q' "$recovery_entrypoint") --cmd $(printf '%q' "$(basename "$SKILL_DIR")")"
[ "$KEEP_DATA" = true ] && recovery_prefix="$recovery_prefix --keep-data"
[ "$AUTO_YES" = true ] && recovery_prefix="$recovery_prefix --yes"
recovery_prefix="$recovery_prefix --recover"
if [ -n "$RECOVER_ID" ]; then
agmsg_install_op_pending_recover "$pending" "$RECOVER_ID" uninstall "$operation_mode" || return 1
RECOVER_ID=""
elif [ -e "$pending" ] || [ -L "$pending" ]; then
agmsg_install_op_pending_refuse "$pending" "$recovery_prefix" uninstall "$operation_mode"
return 1
fi
AGMSG_INSTALL_OP_MARKER="$pending"
agmsg_install_op_pending_begin "$pending" uninstall "$SKILL_DIR" "" "$operation_mode" || return 1
uninstaller_retired="$SKILL_DIR/.install-op-uninstaller-retired.$AGMSG_INSTALL_OP_ID"
if [ -n "$running_entrypoint" ] && [ ! -f "$SKILL_DIR/uninstall.sh" ]; then
uninstaller_retired="$running_entrypoint"
fi
# This install's own path with its trailing slash (review): matching on
# SKILL_NAME or a bare SKILL_DIR prefix is not a boundary -- "agmsg" is a
# literal substring of "agmsg-second", and "$SKILL_DIR" (no trailing
# slash) is a literal PREFIX of "$SKILL_DIR-second", so either one also
# matches a sibling install's own path/hooks/commands. The trailing "/"
# is what "agmsg-second"/"$SKILL_DIR-second" can never contain right after
# this install's own name/path. Rendered content embeds the real absolute
# path (e.g. hook commands, scripts/delivery.sh), never a "~"-shortened
# one, so matching the expanded SKILL_DIR is correct here.
local SKILL_DIR_SLASH="$SKILL_DIR/"
# --- Remove slash commands and hooks from joined projects ---
local TEAMS_DIR="$SKILL_DIR/teams"
if [ -d "$TEAMS_DIR" ]; then
echo " Scanning joined projects for commands and hooks..."
local config
for config in "$TEAMS_DIR"/*/config.json; do
[ -f "$config" ] || continue
# A member's registrations moved into a '$.registrations' array (to
# support more than one project per agent) some time after this query
# was written; it kept reading '$.type'/'$.project' straight off the
# agent, which that array shape never has -- so it matched nothing,
# ever, against a config.json in the current shape, and this whole
# project-cleanup pass was silently a no-op. Falls back to reading
# them straight off the agent for a not-yet-migrated record, the same
# two-shape handling agmsg_registered_type (resolve-project.sh) uses.
local projects
projects=$(sqlite3 -separator ' ' :memory: \
".param set :json '$(sed "s/'/''/g" "$config")'" \
"WITH agent AS (
SELECT CASE
WHEN json_type(json_extract(value, '$.registrations')) = 'array' THEN json_extract(value, '$.registrations')
ELSE json_array(json_object('type', json_extract(value, '$.type'), 'project', json_extract(value, '$.project')))
END AS registrations
FROM json_each(json_extract(:json, '$.agents'))
)
SELECT json_extract(value, '$.project') FROM agent, json_each(agent.registrations)
WHERE json_extract(value, '$.type') = 'claude-code'
AND json_extract(value, '$.project') IS NOT NULL;" 2>/dev/null || true)
local project
while IFS= read -r project; do
[ -n "$project" ] || continue
# Remove command files that reference THIS install's own scripts
# (review: a bare "mentions any agmsg script name" match, with no
# install identity in it at all, removed every install's command
# file from a project more than one had joined -- not just a
# same-prefix collision).
if [ -d "$project/.claude/commands" ]; then
local cmd_file
for cmd_file in "$project/.claude/commands"/*.md; do
[ -f "$cmd_file" ] || continue
if grep -qF "$SKILL_DIR_SLASH" "$cmd_file" 2>/dev/null; then
local cmd_name; cmd_name=$(basename "$cmd_file" .md)
_uninstall_checked_rm "$cmd_file" || return 1
echo " - removed /$cmd_name command from $project"
REMOVED=true
fi
done
fi
# Remove only THIS install's own hook entries from settings files
# (preserve other hooks, and another install's own -- review).
local settings_file
for settings_file in "$project/.claude/settings.json" "$project/.claude/settings.local.json"; do
if [ -f "$settings_file" ] && grep -qF "$SKILL_DIR_SLASH" "$settings_file" 2>/dev/null; then
local SETTINGS_ESC UPDATED
SETTINGS_ESC=$(sed "s/'/''/g" "$settings_file")
UPDATED=$(sqlite3 :memory: "
WITH hook_types(ht) AS (VALUES ('Stop'), ('PostToolUse'))
SELECT COALESCE(
(SELECT result FROM (
SELECT '$SETTINGS_ESC' AS result
) WHERE NOT EXISTS (
SELECT 1 FROM hook_types, json_each(json_extract('$SETTINGS_ESC', '\$.hooks.' || ht)) AS e,
json_each(json_extract(e.value, '\$.hooks')) AS h
WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_DIR_SLASH') > 0
)),
(SELECT CASE
WHEN (SELECT count(*) FROM json_each(json_extract(filtered, '\$.hooks'))
WHERE json_array_length(value) > 0 OR json_type(value) != 'array') = 0
THEN json_remove(filtered, '\$.hooks')
ELSE filtered
END
FROM (
SELECT json_set(json_set('$SETTINGS_ESC',
'\$.hooks.Stop',
COALESCE((SELECT json_group_array(json(e.value))
FROM json_each(json_extract('$SETTINGS_ESC', '\$.hooks.Stop')) AS e
WHERE NOT EXISTS (
SELECT 1 FROM json_each(json_extract(e.value, '\$.hooks')) AS h
WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_DIR_SLASH') > 0
)), json('[]'))),
'\$.hooks.PostToolUse',
COALESCE((SELECT json_group_array(json(e.value))
FROM json_each(json_extract('$SETTINGS_ESC', '\$.hooks.PostToolUse')) AS e
WHERE NOT EXISTS (
SELECT 1 FROM json_each(json_extract(e.value, '\$.hooks')) AS h
WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_DIR_SLASH') > 0
)), json('[]'))) AS filtered
))
);
" 2>/dev/null) || true
if [ -n "$UPDATED" ] && [ "$UPDATED" != "$SETTINGS_ESC" ]; then
_uninstall_operation_require || return 1
echo "$UPDATED" > "$settings_file"
_uninstall_operation_require || return 1
echo " - removed agmsg hook from $settings_file"
REMOVED=true
fi
fi
done
done <<< "$projects"
# --- Copilot CLI project-scoped hook file cleanup ---
# Same two-shape registrations handling as the claude-code query above.
local copilot_projects
copilot_projects=$(sqlite3 -separator ' ' :memory: \
".param set :json '$(sed "s/'/''/g" "$config")'" \
"WITH agent AS (
SELECT CASE
WHEN json_type(json_extract(value, '$.registrations')) = 'array' THEN json_extract(value, '$.registrations')
ELSE json_array(json_object('type', json_extract(value, '$.type'), 'project', json_extract(value, '$.project')))
END AS registrations
FROM json_each(json_extract(:json, '$.agents'))
)
SELECT json_extract(value, '$.project') FROM agent, json_each(agent.registrations)
WHERE json_extract(value, '$.type') = 'copilot'
AND json_extract(value, '$.project') IS NOT NULL;" 2>/dev/null || true)
while IFS= read -r project; do
[ -n "$project" ] || continue
local copilot_hook="$project/.github/hooks/agmsg.json"
if [ -f "$copilot_hook" ] && grep -qF "$SKILL_DIR_SLASH" "$copilot_hook" 2>/dev/null; then
_uninstall_checked_rm "$copilot_hook" || return 1
echo " - removed agmsg Copilot hook from $project"
REMOVED=true
fi
done <<< "$copilot_projects"
# --- Grok Build CLI project-scoped rule file cleanup ---
# Same two-shape registrations handling as the claude-code query above.
# #1469: install.sh's own comment describes this as a hook under
# ~/.grok/hooks/, but that path is written nowhere in this codebase --
# scripts/drivers/types/grok-build/type.conf's actual hooks_file is
# .grok/rules/agmsg.md, project-relative, written by
# grok-build/_delivery.sh's own agmsg_delivery_apply (turn/monitor
# mode). That install.sh comment is stale; this cleans the file that
# is actually written, the same way the Copilot hook above is cleaned.
local grok_projects
grok_projects=$(sqlite3 -separator ' ' :memory: \
".param set :json '$(sed "s/'/''/g" "$config")'" \
"WITH agent AS (
SELECT CASE
WHEN json_type(json_extract(value, '$.registrations')) = 'array' THEN json_extract(value, '$.registrations')
ELSE json_array(json_object('type', json_extract(value, '$.type'), 'project', json_extract(value, '$.project')))
END AS registrations
FROM json_each(json_extract(:json, '$.agents'))
)
SELECT json_extract(value, '$.project') FROM agent, json_each(agent.registrations)
WHERE json_extract(value, '$.type') = 'grok-build'
AND json_extract(value, '$.project') IS NOT NULL;" 2>/dev/null || true)
while IFS= read -r project; do
[ -n "$project" ] || continue
local grok_rule="$project/.grok/rules/agmsg.md"
if [ -f "$grok_rule" ] && grep -qF "$SKILL_DIR_SLASH" "$grok_rule" 2>/dev/null; then
_uninstall_checked_rm "$grok_rule" || return 1
echo " - removed agmsg Grok Build rule from $project"
REMOVED=true
fi
done <<< "$grok_projects"
done
fi
# --- Remove Claude Code global command ---
local CC_CMD="$INSTALL_HOME/.claude/commands/$SKILL_NAME.md"
if [ -f "$CC_CMD" ]; then
_uninstall_checked_rm "$CC_CMD" || return 1
echo " - removed /$SKILL_NAME from $INSTALL_HOME_SHOWN/.claude/commands/"
REMOVED=true
fi
# --- Remove Copilot CLI skill ---
local COPILOT_SKILL="$INSTALL_HOME/.copilot/skills/$SKILL_NAME"
if [ -d "$COPILOT_SKILL" ]; then
_uninstall_checked_rm -rf "$COPILOT_SKILL" || return 1
echo " - removed /$SKILL_NAME skill from $INSTALL_HOME_SHOWN/.copilot/skills/"
REMOVED=true
fi
# --- Remove Antigravity skill ---
local ANTIGRAVITY_SKILL="$INSTALL_HOME/.gemini/config/skills/$SKILL_NAME"
if [ -d "$ANTIGRAVITY_SKILL" ]; then
_uninstall_checked_rm -rf "$ANTIGRAVITY_SKILL" || return 1
echo " - removed /$SKILL_NAME skill from $INSTALL_HOME_SHOWN/.gemini/config/skills/"
REMOVED=true
fi
# --- Remove native Windows helpers ---
local helper
for helper in "$AGENTS_DIR/$SKILL_NAME.ps1" "$AGENTS_DIR/$SKILL_NAME-run.sh"; do
if [ -f "$helper" ]; then
_uninstall_checked_rm "$helper" || return 1
echo " - removed $helper"
REMOVED=true
fi
done
# --- Remove the agmsg command launcher this install placed ---
# Only a launcher that carries our marker, names this install, and still has
# the content we wrote is removed. Older installs have no launcher library;
# there is then nothing of ours to remove.
local _launcher_lib
for _launcher_lib in "$SKILL_DIR/scripts/lib/agmsg-launcher.sh" "$SCRIPT_DIR/scripts/lib/agmsg-launcher.sh"; do
[ -r "$_launcher_lib" ] || continue
# shellcheck disable=SC1090
. "$_launcher_lib"
AGMSG_LAUNCHER_RM=_uninstall_checked_rm agmsg_launcher_uninstall "$SKILL_DIR" || return 1
break
done
# --- State kept outside the skills directory ---
# install.sh links run/ db/ teams/ ext-tools/ .trash/ to <state base>/<name>/
# (scripts/lib/state-root.sh). The links go with the skill directory below;
# the data they point at is removed here, by the same rules: with --keep-data
# only the backup copies in .trash go, otherwise everything but the install
# operation lock database.
local STATE_PARENT="" STATE_PARENT_REAL="" _state_part _state_target full_remove=false
for _state_part in db teams ext-tools .trash run; do
if [ -L "$SKILL_DIR/$_state_part" ]; then
_state_target="$(readlink "$SKILL_DIR/$_state_part")"
case "$_state_target" in
/*/"$_state_part") STATE_PARENT="${_state_target%/*}" ;;
esac
fi
done
unset _state_part _state_target
# The Codex entries carry the resolved spelling of that path (/private/var
# for /var on macOS); read before the directory goes.
if [ -n "$STATE_PARENT" ]; then
STATE_PARENT_REAL="$(cd -P "$STATE_PARENT" 2>/dev/null && pwd -P)" || STATE_PARENT_REAL=""
fi
# --- Remove the skill directory ---
if [ "$KEEP_DATA" = true ]; then
echo ""
echo " Removing $SKILL_NAME skill (keeping DB and teams)..."
_uninstall_checked_rm -rf "$SKILL_DIR/scripts" "$SKILL_DIR/templates" "$SKILL_DIR/agents" "$SKILL_DIR/.trash" || return 1
if [ -n "$STATE_PARENT" ] && [ -d "$STATE_PARENT/.trash" ]; then
_uninstall_checked_rm -rf "$STATE_PARENT/.trash" || return 1
fi
_uninstall_checked_rm -f "$SKILL_DIR/SKILL.md" "$SKILL_DIR/CHANGELOG.md" || return 1
echo " - removed scripts, templates, SKILL.md, CHANGELOG.md"
echo " ~ preserved $SKILL_DIR/db/ and $SKILL_DIR/teams/${STATE_PARENT:+ (kept in $STATE_PARENT)}"
REMOVED=true
else
echo ""
if confirm "Remove $SKILL_NAME (including DB and teams)?"; then
# .install-op/lock.db (in the install directory, which a state move never
# touches) is NEVER deleted, even here (agmsgd beta):
# removing a DB a waiter still has open makes a
# freshly recreated file of the same name a DIFFERENT lock than the
# one the waiter holds a reference to -- see install-op-lock.sh's own
# header). Remove every top-level entry EXCEPT run/ and the lock by name, then
# inside run/ remove everything except install-op.lock.db by name --
# never a single recursive rm -rf "$SKILL_DIR" that cannot make this
# one exception. rmdir (not rm -rf) on SKILL_DIR itself: it correctly
# fails and is left in place, since .install-op/lock.db means it
# is never truly empty after this.
local _entry _run_entry
for _entry in "$SKILL_DIR"/* "$SKILL_DIR"/.[!.]*; do
[ -e "$_entry" ] || continue
# Keep the installed recovery entrypoint available until the
# incomplete-operation record is cleared below.
case "$(basename "$_entry")" in
uninstall.sh|.install-op-uninstaller-retired.*|.install-op) continue ;;
esac
if [ "$(basename "$_entry")" = "run" ]; then
for _run_entry in "$_entry"/*; do
[ -e "$_run_entry" ] || continue
case "$(basename "$_run_entry")" in
install-op.lock.db|install-op-incomplete.json|install-op-recovery.sh|.install-op-recovery-retired.*) continue ;;
esac
_uninstall_checked_rm -rf "$_run_entry" || return 1
done
else
_uninstall_checked_rm -rf "$_entry" || return 1
fi
done
unset _entry _run_entry
if [ -n "$STATE_PARENT" ]; then
local _state_dir
for _state_dir in db teams ext-tools .trash; do
[ -d "$STATE_PARENT/$_state_dir" ] || continue
_uninstall_checked_rm -rf "$STATE_PARENT/$_state_dir" || return 1
done
unset _state_dir
echo " - removed the state kept in $STATE_PARENT"
fi
_uninstall_operation_require || return 1
rmdir "$SKILL_DIR" 2>/dev/null || true
echo " - removed $SKILL_DIR (kept .install-op/lock.db and the active operation record)"
REMOVED=true
full_remove=true
fi
fi
# --- Clean up Codex writable_roots (this install's own path only) ---
# Clean only the active profile, using the same selection as installation.
local _codex_cfg
while IFS= read -r _codex_cfg; do
local _clean_dirs=("$SKILL_DIR")
if [ -n "$STATE_PARENT" ]; then
_clean_dirs+=("$STATE_PARENT")
if [ -n "$STATE_PARENT_REAL" ] && [ "$STATE_PARENT_REAL" != "$STATE_PARENT" ]; then
_clean_dirs+=("$STATE_PARENT_REAL")
fi
fi
_uninstall_clean_codex_config "$_codex_cfg" "${_clean_dirs[@]}" || return 1
done < <(agmsg_codex_config_paths)
# --- Remove OpenCode, Hermes, and Grok Build skill files ---
# Each mirrors install.sh's own SKILL_DIR construction and gating
# (scripts/drivers/types/{opencode,hermes,grok-build}, install.sh) --
# these three were the ones #1469 found install.sh writes but uninstall.sh
# never removed. Deletes the exact file this install wrote, by name, then
# rmdir (never rm -rf): rmdir only succeeds on an EMPTY directory, so
# anything unexpected sharing that directory is left alone and reported,
# rather than pulled in by a recursive delete that cannot tell the
# difference (review).
local _dedicated_dir_label _dedicated_dir _dedicated_label
for _dedicated_dir_label in \
"$INSTALL_HOME/.config/opencode/skills/$SKILL_NAME|OpenCode" \
"$INSTALL_HOME/.hermes/skills/$SKILL_NAME|Hermes" \
"$INSTALL_HOME/.grok/skills/$SKILL_NAME|Grok Build"
do
_dedicated_dir="${_dedicated_dir_label%%|*}"
_dedicated_label="${_dedicated_dir_label#*|}"
if [ -f "$_dedicated_dir/SKILL.md" ]; then
_uninstall_checked_rm -f "$_dedicated_dir/SKILL.md" || return 1
if rmdir "$_dedicated_dir" 2>/dev/null; then
echo " - removed /$SKILL_NAME $_dedicated_label skill"
else
echo " - removed /$SKILL_NAME $_dedicated_label skill (SKILL.md only; $_dedicated_dir left in place, not empty)"
fi
REMOVED=true
fi
done
unset _dedicated_dir_label _dedicated_dir _dedicated_label
if [ -f "$recovery_helper" ]; then
recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID"
_uninstall_operation_require || return 1
mv "$recovery_helper" "$recovery_retired" || return 1
_uninstall_operation_require || return 1
elif [ -n "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ] && [ -f "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" ]; then
recovery_retired="$SKILL_DIR/run/.install-op-recovery-retired.$AGMSG_INSTALL_OP_ID"
_uninstall_operation_require || return 1
mv "$AGMSG_INSTALL_OP_RECOVERY_SOURCE" "$recovery_retired" || return 1
_uninstall_operation_require || return 1
fi
if [ "$KEEP_DATA" = false ] && [ -f "$SKILL_DIR/uninstall.sh" ]; then
_uninstall_operation_require || return 1
if ! agmsg_install_op_run_writer mv "$SKILL_DIR/uninstall.sh" "$uninstaller_retired"; then
echo " ! could not preserve the installed recovery entrypoint: $SKILL_DIR/uninstall.sh" >&2
return 1
fi
_uninstall_operation_require || return 1
fi
agmsg_install_op_pending_complete "$AGMSG_INSTALL_OP_MARKER" "$AGMSG_INSTALL_OP_ID" || {
echo " ! could not clear the completed-operation record; later changes are blocked pending recovery" >&2
return 1
}
if [ -n "$uninstaller_retired" ] && [ -e "$uninstaller_retired" ]; then
# Remove only this generation's retired path after the record is cleared;
# a later install writes uninstall.sh and cannot be removed by this cleanup.
rm -f "$uninstaller_retired" || {
echo " ! uninstall completed but could not remove its retired entrypoint: $uninstaller_retired" >&2
return 1
}
fi
if [ -n "$recovery_retired" ]; then
rm -f "$recovery_retired" || {
echo " ! uninstall completed but could not remove its temporary recovery helper: $recovery_retired" >&2
return 1
}
fi
if [ "$full_remove" = true ] && [ ! -e "$SKILL_DIR/SKILL.md" ] && [ ! -e "$SKILL_DIR/scripts" ]; then
# Still holding the install lock and with the record cleared: take away what
# is left of this install, and only that -- its own link to the state, the
# state root's owner file and whatever else the state's run/ holds, and the
# directories that are then empty (rmdir, which leaves any directory that
# still holds something). Nothing else can be installing here while the lock
# is held, so no later install's link or file is taken (and nothing is
# touched if one has already written its skill files). The lock databases
# stay, as they always have: a waiter that holds one open would otherwise be
# left with a different lock from the next installer's.
local _left
if [ -L "$SKILL_DIR/run" ]; then
rm -f "$SKILL_DIR/run"
fi
if [ -n "$STATE_PARENT" ] && [ -d "$STATE_PARENT/run" ]; then
for _left in "$STATE_PARENT/run"/* "$STATE_PARENT/run"/.[!.]*; do
[ -e "$_left" ] || continue
case "${_left##*/}" in install-op.lock.db*) continue ;; esac
rm -f "$_left"
done
fi
if [ -n "$STATE_PARENT" ] && [ -f "$STATE_PARENT/.owner" ]; then
rm -f "$STATE_PARENT/.owner"
fi
rmdir "$SKILL_DIR" 2>/dev/null || true
unset _left
fi
agmsg_install_op_unlock
AGMSG_INSTALL_OP_ACTIVE=false
unset AGMSG_INSTALL_OP_ID AGMSG_INSTALL_OP_MARKER
}
# Machine-wide pieces, shared by every install: only safe to remove once NO
# agmsg install remains on the machine to still need them. Sets the global
# REMOVED=true on any change.
_uninstall_shared_pieces() {
local SQLITE_SHIM="$AGENTS_DIR/bin/sqlite3"
local REMOVED_SQLITE_SHIM=false
if [ -f "$SQLITE_SHIM" ] && grep -q "sqlite3 compatibility shim for agmsg" "$SQLITE_SHIM" 2>/dev/null; then
rm "$SQLITE_SHIM"
echo " - removed $SQLITE_SHIM"
REMOVED=true
REMOVED_SQLITE_SHIM=true
fi
local SQLITE_SHIM_CACHE="$AGENTS_DIR/run/sqlite3-shim.cache"
if [ "$REMOVED_SQLITE_SHIM" = true ] && [ -f "$SQLITE_SHIM_CACHE" ]; then
rm "$SQLITE_SHIM_CACHE"
echo " - removed $SQLITE_SHIM_CACHE"
REMOVED=true
fi
# A same-named non-agmsg file is left alone -- the owner-comment signature
# is what confirms this is genuinely an agmsg-written shim, not who wrote
# it: with no install left, whichever one wrote it no longer matters.
local ANTIGRAVITY_TUI_SHIM="$AGENTS_DIR/bin/agy-tui"
if [ -f "$ANTIGRAVITY_TUI_SHIM" ] && grep -q "^# agmsg-shim-owner: " "$ANTIGRAVITY_TUI_SHIM" 2>/dev/null; then
rm "$ANTIGRAVITY_TUI_SHIM"
echo " - removed $ANTIGRAVITY_TUI_SHIM"
REMOVED=true
fi
}
# True (0) iff no ~/.agents/skills/*/ carries the .agmsg marker any more.
# Scanned FRESH, after the removal(s) above ran -- never decided from a
# count taken before them (review): a KEEP_DATA run (--keep-data, or "n" to
# the interactive "remove DB and teams too?") never deletes the marker
# file, on purpose, so that install still counts as present, and the
# machine-wide shared pieces below must stay in that case even though this
# run's own OTHER_SKILL_DIRS/ALL_SKILL_DIRS count (taken before removal)
# said otherwise.
_uninstall_none_remain() {
local d
for d in "$AGENTS_DIR"/skills/*/; do
[ -f "${d}.agmsg" ] && return 1
done
return 0
}
if [ "$REMOVE_ALL" = true ]; then
# --- --all: every agmsg install on the machine (#1400 follow-up) ---
# The pre-fix behavior, restored, but only when explicitly asked for --
# works the same no matter which install's uninstall.sh (or a repo
# checkout's ./uninstall.sh) this is run from, since it enumerates every
# marker unconditionally rather than resolving one.
ALL_SKILL_DIRS=()
for d in "$AGENTS_DIR"/skills/*/; do
d="${d%/}"
[ -f "$d/.agmsg" ] && ALL_SKILL_DIRS+=("$d")
done
if [ ${#ALL_SKILL_DIRS[@]} -eq 0 ]; then
echo " Nothing to remove (not installed?)"
echo ""
exit 0
fi
echo " Removing ALL installations:"
for d in "${ALL_SKILL_DIRS[@]}"; do
echo " $(basename "$d") → $d"
done
echo ""
if [ "$AUTO_YES" != true ]; then
if ! confirm "Remove ALL ${#ALL_SKILL_DIRS[@]} installation(s) listed above?"; then
echo " Aborted."
echo ""
exit 0
fi
# A SEPARATE question (review): saying yes to removing every INSTALL is
# not the same claim as saying yes to erasing every install's DB
# (message history) and teams too -- the first question never says
# that, and answering it must not be read as having answered this one.
# Answered once here, applied to every install below via KEEP_DATA, not
# re-asked per install. Skipped when --keep-data already said no.
if [ "$KEEP_DATA" != true ] && ! confirm "Also remove each install's DB (message history) and teams?"; then
KEEP_DATA=true
fi
# Both questions above stand in for the per-install "keep DB and teams?"
# prompt inside _uninstall_one, which must not ask again, once per
# install, for what this run already answered.
AUTO_YES=true
fi
for d in "${ALL_SKILL_DIRS[@]}"; do
echo ""
echo " --- $(basename "$d") ---"
_uninstall_one "$d"
done
echo ""
if _uninstall_none_remain; then
_uninstall_shared_pieces
fi
else
# --- This install only (#1400) ---
#
# Earlier this iterated every ~/.agents/skills/*/ carrying an `.agmsg`
# marker -- every OTHER install on the machine, not just this one -- and
# removed all of their commands, skills, hooks and writable_roots entries:
# uninstalling one throwaway install wiped every install on the machine.
# Deciding which ONE install this run is about, in order:
# 1. uninstall.sh ships INSIDE each install (copied there by install.sh)
# and is normally run from there, so when $0's own directory carries
# the marker, that unambiguously IS this run's install.
# 2. $0 does not identify one (e.g. run from a kept git checkout, the way
# this project's own tests do): a single install on the machine is
# still unambiguous. More than one refuses rather than guess --
# pointing at each one's own uninstall.sh, or --all to remove every
# install on the machine at once.
SELF_SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -n "$CMD_NAME" ]; then
SELF_SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME"
if [ ! -d "$SELF_SKILL_DIR" ]; then
echo " ! selected installation does not exist: $SELF_SKILL_DIR" >&2
exit 1
fi
elif [ ! -f "$SELF_SKILL_DIR/.agmsg" ]; then
candidates=()
for d in "$AGENTS_DIR"/skills/*/; do
d="${d%/}"
[ -f "$d/.agmsg" ] && candidates+=("$d")
done
case "${#candidates[@]}" in
0)
echo " Nothing to remove (not installed?)"
echo ""
exit 0
;;
1) SELF_SKILL_DIR="${candidates[0]}" ;;
*)
echo " ! Several agmsg installs found:" >&2
for d in "${candidates[@]}"; do
echo " $d/uninstall.sh" >&2
done
echo " ! Cannot tell which one to uninstall. Run the uninstall.sh inside the one you want to remove, or pass --all to remove them all." >&2
exit 1
;;
esac
fi
# Other installs, purely to decide whether the machine-wide shared pieces
# are still needed below -- never touched otherwise. Two installs can
# legitimately coexist (different --cmd names to install.sh), and one
# going away must not disturb the others.
OTHER_SKILL_DIRS=()
for d in "$AGENTS_DIR"/skills/*/; do
[ -f "${d}.agmsg" ] || continue
[ "${d%/}" = "$SELF_SKILL_DIR" ] && continue
OTHER_SKILL_DIRS+=("${d%/}")
done
echo " Removing installation:"
echo " $(basename "$SELF_SKILL_DIR") → $SELF_SKILL_DIR"
if [ ${#OTHER_SKILL_DIRS[@]} -gt 0 ]; then
echo " Other installation(s) found, left untouched:"
for sd in "${OTHER_SKILL_DIRS[@]}"; do
echo " $(basename "$sd") → $sd"
done
fi
echo ""
_uninstall_one "$SELF_SKILL_DIR"
if _uninstall_none_remain; then
_uninstall_shared_pieces
fi
fi
# --- Clean up empty ~/.agents/ ---
if [ -d "$AGENTS_DIR" ]; then
rmdir "$AGENTS_DIR/bin" 2>/dev/null || true
rmdir "$AGENTS_DIR/skills" 2>/dev/null || true
rmdir "$AGENTS_DIR" 2>/dev/null || true
fi
# --- Done ---
echo ""
if [ "$REMOVED" = true ]; then
echo " ✓ Uninstall complete"
else
echo " Nothing removed."
fi
echo ""