-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1061 lines (1004 loc) · 49.5 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·1061 lines (1004 loc) · 49.5 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
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# install.sh — wire the live system at ~/.claude/ + ~/Library/LaunchAgents/
# to the canonical code in this repo at ~/dev/assistant/.
#
# Idempotent: re-run it freely. Default is --dry-run; pass --apply to mutate.
#
# Strategy:
# - Code (bin/, prompts/, lessons/) is symlinked from ~/.claude/* to this
# repo, so edits go live without copying.
# - LaunchAgent plists are COPIED into ~/Library/LaunchAgents/ (launchd does
# not follow symlinks reliably) and then unloaded + reloaded.
# - Skills are SYMLINKED per-name into ~/.claude/skills/<name> → repo's
# skills/<name>/. The repo is the single source of truth: a pull is live
# immediately, and the pulse self-update can never clobber a live edit by
# re-copying (it used to — see Section 2). Other skills under
# ~/.claude/skills/ are left untouched.
# - Runtime state stays where it is (~/.claude/cache/, ~/.claude/projects/,
# ~/.claude/assistant-todo.json, ~/.claude/assistant-dashboard.html). This
# install never touches those.
#
# What gets backed up:
# - Anything currently at the target path that is NOT already a symlink to
# the repo's expected source is moved aside to <target>.bak-<unix-ts>.
# You can `rm -rf` those once you're confident the install is good.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOME_DIR="${HOME}"
APPLY=0
PULL_SKILLS=0
# Opt-in feature daemons: off by default (core-only install). Each --with flag
# turns one on. See the "Daemon tiers" block in Section 3.
WITH_MEMORY=0
WITH_CRASH_RESUME=0
TS="$(date +%s)"
log() { printf '%s\n' "$*"; }
note() { printf ' · %s\n' "$*"; }
warn() { printf ' ⚠ %s\n' "$*" >&2; }
# --- argv parsing -----------------------------------------------------------
for arg in "$@"; do
case "$arg" in
--apply) APPLY=1 ;;
--dry-run) APPLY=0 ;;
--pull-skills) PULL_SKILLS=1 ;;
--with-memory) WITH_MEMORY=1 ;;
--with-crash-resume) WITH_CRASH_RESUME=1 ;;
--with-all) WITH_MEMORY=1; WITH_CRASH_RESUME=1 ;;
-h|--help)
cat <<USAGE
install.sh — install/update the Assistant system from $REPO_ROOT
--dry-run (default) Show what would change. No mutation.
--apply Actually create symlinks/copies, copy plists, reload launchd.
--pull-skills Pull edits from a live ~/.claude/skills/<name>/ directory
BACK into the repo. Only needed to recover edits made while
a skill was still a real directory (e.g. backups left by the
copy→symlink migration). Once a skill is symlinked, live
edits ARE repo edits, so this becomes a no-op for it.
Prints a unified diff in dry-run; run with --apply to copy.
Opt-in feature daemons (off by default — a bare --apply installs CORE only:
the fleet loop pulse/world-scanner/session-context-watcher/assistant-page/
todo-server). On an interactive --apply (a real terminal), install.sh OFFERS
each undecided feature with a one-line explanation and a [y/N] prompt, so you
discover them without reading docs. Your answer is remembered in
~/.assistant/feature-opt-in, so re-runs never re-ask. Headless runs (pulse
self-update, curl|bash, CI) never prompt — they default to OFF.
--with-memory cross-machine memory sync (memory-sync-pull). Only useful
if you run Assistant on more than one machine.
--with-crash-resume auto-resume crashed cmux workspaces (workspace-watcher).
--with-all enable all of the above (and suppress their prompts).
(Slack comms + slack-reactor are always copied but hand-loaded after their
token setup — see ONBOARDING.md — never auto-loaded even with a flag.)
Changed your mind? Declined a feature and want it now: re-run with its --with
flag (the flag beats a remembered 'no'). To be re-asked from scratch: delete
its line from ~/.assistant/feature-opt-in. To disable a running one:
launchctl bootout gui/\$UID/com.assistant.<label>, then set its line to 'no'.
Flags/answers only affect what gets LOADED; an already-running feature daemon
is never torn out (it's adopted and remembered as enabled).
-h, --help This help.
After --apply:
- ~/.claude/bin → symlink → $REPO_ROOT/bin
- legacy ~/.claude/spawn-prompts/prompt-{assistant,triage}-agent.md → REMOVED
(old LLM-Assistant era; the mechanical pulse.py reads prompts/ directly)
- lessons live in ~/.claude/CLAUDE.md (not in this repo). Curator: bin/assistant-curator.py
- ~/.claude/skills/<name> → SYMLINK → $REPO_ROOT/skills/<name> (repo is truth)
- CORE plists loaded: com.assistant.{assistant-pulse,world-scanner,
session-context-watcher,assistant-page,assistant-todo-server}
- FEATURE plists copied, loaded only with their --with flag: memory-sync-pull
(--with-memory), workspace-watcher (--with-crash-resume); comms +
slack-reactor copied but hand-loaded after token setup
- cmux session-restore (vendored): hooks/ → ~/.claude/hooks/ (symlinks),
bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions,
and ~/.claude/settings.json SessionStart/SessionEnd hooks patched in
- launchd: kickstart -k each agent (load if not loaded)
Skills are symlinked (not copied), so the repo is the single source of truth:
in-place edits to a skill ARE repo edits, a pull is live immediately, and the
pulse self-update can never revert a live edit by re-copying. A pre-existing
real directory at the target is backed up to ~/.claude/skills-backups/ before
the symlink replaces it; recover edits from there with --pull-skills.
What is NOT touched:
- ~/.claude/cache/, ~/.claude/projects/, ~/.claude/cmux-registry.json
- ~/.claude/assistant-todo.json, ~/.claude/assistant-dashboard.html
- ~/.claude/skills/cmux/ and any other unrelated skills
- ~/.architect/
USAGE
exit 0 ;;
*) warn "unknown arg: $arg"; exit 2 ;;
esac
done
# --- feature opt-in resolution ----------------------------------------------
# Turn the WITH_* flags into final load decisions, offering a DISCOVERY PROMPT
# for undecided features on an interactive --apply. Precedence per feature
# (first match wins): explicit --with flag > remembered answer > daemon already
# loaded > interactive prompt > headless default-NO.
#
# Guard lineage: mirrors the phase-7 memory step ([[ ! -t 0 ]] skip + a state
# file for idempotence). A prompt fires ONLY on an interactive --apply that is
# NOT a self-update; all headless contexts (pulse self-update, curl|bash, CI)
# fall through to default-NO WITHOUT persisting, so discovery survives to the
# user's first real interactive run. NEVER hangs: no bare `read` (set -e would
# abort on EOF), and a walk-away is bounded by read -t.
STATE_FILE="$HOME_DIR/.assistant/feature-opt-in"
# state_get <feat> → prints yes|no|"" (empty = never decided). `local` masks
# grep's exit-1-on-no-match so set -e can't abort.
state_get() {
local v
v="$(grep "^$1=" "$STATE_FILE" 2>/dev/null | tail -1 | cut -d= -f2)"
printf '%s' "$v"
}
# state_set <feat> <yes|no> — APPLY-only, atomic last-write-wins. The `|| true`
# after grep -v is mandatory (no-match exits 1 under set -e).
state_set() {
[[ $APPLY -eq 1 ]] || return 0
mkdir -p "$(dirname "$STATE_FILE")"
touch "$STATE_FILE"
local tmp="$STATE_FILE.tmp.$$"
{ grep -v "^$1=" "$STATE_FILE" 2>/dev/null || true; printf '%s=%s\n' "$1" "$2"; } > "$tmp" \
&& mv "$tmp" "$STATE_FILE"
}
explicit_flag_for() {
case "$1" in
memory) echo "$WITH_MEMORY" ;;
crash-resume) echo "$WITH_CRASH_RESUME" ;;
*) echo 0 ;;
esac
}
_feat_label() {
case "$1" in
memory) echo "com.assistant.memory-sync-pull" ;;
crash-resume) echo "com.assistant.workspace-watcher" ;;
esac
}
# daemon_loaded <feat> — 0 if the feature's LaunchAgent is loaded. Called ONLY
# inside a condition (never bare) so its nonzero-when-absent can't trip set -e.
daemon_loaded() { launchctl print "gui/$UID/$(_feat_label "$1")" >/dev/null 2>&1; }
feat_desc() {
case "$1" in
memory) echo "Memory sync keeps your lessons + semantic memory identical across all your machines (needs a private git repo; only useful on 2+ machines). Enable? [y/N] " ;;
crash-resume) echo "Crash-resume auto-restarts a cmux workspace whose Claude session died, so long jobs survive a crash or reboot. Enable? [y/N] " ;;
esac
}
# prompt_yn <question> → 0 on yes. Sets PROMPT_TIMED_OUT=1 iff read timed out
# (exit >128) so the caller can distinguish a walk-away (do NOT persist) from an
# explicit decline (persist no). The if/else wrapper is mandatory: a bare read
# returns nonzero on EOF and set -e would abort the whole install.
PROMPT_TIMED_OUT=0
prompt_yn() {
local q="$1" ans rc
PROMPT_TIMED_OUT=0
if read -r -t 60 -p "$q" ans; then rc=0; else rc=$?; fi
# bash: read exits >128 specifically on -t timeout; EOF exits 1.
if [[ ${rc:-0} -gt 128 ]]; then PROMPT_TIMED_OUT=1; ans=""; fi
case "$ans" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# feature_should_prompt <feat> — the full guard, all terms AND-ed. Each headless
# context is blocked by ≥2 independent terms (self-update fails both -t 0 AND the
# env term; curl|bash & CI fail -t 0).
feature_should_prompt() {
[[ $APPLY -eq 1 ]] || return 1
[[ -t 0 ]] || return 1
[[ "${ASSISTANT_SELF_UPDATE:-0}" != "1" ]] || return 1
[[ "$(explicit_flag_for "$1")" != "1" ]] || return 1
[[ -z "$(state_get "$1")" ]] || return 1
! daemon_loaded "$1" || return 1
return 0
}
_optin_header_shown=0
_optin_header() {
[[ $_optin_header_shown -eq 1 ]] && return 0
_optin_header_shown=1
log "[2.5] Optional features (safe to skip; enable later with --with-<name>)"
}
for _pair in 'memory:WITH_MEMORY' 'crash-resume:WITH_CRASH_RESUME'; do
_feat="${_pair%%:*}"; _var="${_pair##*:}"
if [[ "$(explicit_flag_for "$_feat")" == "1" ]]; then
state_set "$_feat" yes # (a) explicit flag wins
continue
fi
_saved="$(state_get "$_feat")"
if [[ "$_saved" == "yes" ]]; then eval "$_var=1"; continue; fi # (b) remembered yes
if [[ "$_saved" == "no" ]]; then continue; fi # (b) remembered no
if daemon_loaded "$_feat"; then # (c) adopt already-running
eval "$_var=1"; state_set "$_feat" yes; continue
fi
if feature_should_prompt "$_feat"; then # (d) discovery prompt
_optin_header
if prompt_yn "$(feat_desc "$_feat")"; then
eval "$_var=1"; state_set "$_feat" yes
elif [[ $PROMPT_TIMED_OUT -eq 1 ]]; then
note "$_feat: no response (timed out) — left undecided, will re-ask next time"
else
state_set "$_feat" no # explicit decline → remember, stop nagging
fi
fi
# (e) headless-undecided: leave WITH at 0, DO NOT persist (preserve discovery)
done
if [[ $PULL_SKILLS -eq 1 ]]; then
log "↩️ PULL SKILLS mode — copying live ~/.claude/skills/<name>/ → repo"
if [[ $APPLY -eq 0 ]]; then
log " (dry-run; pass --apply to actually overwrite repo files)"
fi
log ""
for skill_dir in "$REPO_ROOT"/skills/*/; do
skill_name="$(basename "$skill_dir")"
live="$HOME_DIR/.claude/skills/$skill_name"
repo_target="$REPO_ROOT/skills/$skill_name"
if [[ -L "$live" ]]; then
note "$skill_name: live is a symlink → already in sync, skipping"
continue
fi
if [[ ! -d "$live" ]]; then
note "$skill_name: not present at $live, skipping"
continue
fi
if diff -rq "$live" "$repo_target" >/dev/null 2>&1; then
note "$skill_name: in sync, nothing to pull"
continue
fi
note "$skill_name: DRIFT detected"
diff -ru "$repo_target" "$live" 2>&1 | sed 's/^/ /' | head -60
if [[ $APPLY -eq 1 ]]; then
rm -rf "$repo_target"
cp -R "$live" "$repo_target"
note "$skill_name: ✓ pulled into repo. Commit with `git add -A skills/`."
fi
done
log ""
if [[ $APPLY -eq 0 ]]; then
log "✅ Pull dry-run complete. Re-run with --pull-skills --apply to write."
else
log "✅ Pull complete. Review with `git diff` and commit."
fi
exit 0
fi
if [[ $APPLY -eq 0 ]]; then
log "📋 DRY RUN — no changes will be made. Re-run with --apply to mutate."
else
log "🔧 APPLYING changes from $REPO_ROOT"
fi
log ""
# --- helpers ----------------------------------------------------------------
# ensure_symlink <target_path> <expected_source>
# Creates target_path → expected_source, backing up anything in the way.
ensure_symlink() {
local target="$1" expected="$2"
local target_parent
target_parent="$(dirname "$target")"
if [[ ! -e "$expected" && ! -L "$expected" ]]; then
warn "source missing: $expected (skipping $target)"
return 1
fi
if [[ -L "$target" ]]; then
local current
current="$(readlink "$target")"
if [[ "$current" == "$expected" ]]; then
note "OK $target → $expected"
return 0
fi
note "FIX $target → was: $current now: $expected"
if [[ $APPLY -eq 1 ]]; then
rm "$target"
ln -s "$expected" "$target"
fi
return 0
fi
if [[ -e "$target" ]]; then
local backup="${target}.bak-${TS}"
note "BACKUP $target → $backup, then symlink → $expected"
if [[ $APPLY -eq 1 ]]; then
mv "$target" "$backup"
mkdir -p "$target_parent"
ln -s "$expected" "$target"
fi
return 0
fi
note "NEW $target → $expected"
if [[ $APPLY -eq 1 ]]; then
mkdir -p "$target_parent"
ln -s "$expected" "$target"
fi
}
# ensure_file_copy <target_path> <source_path>
# Copies source over target, backing up if target exists and differs.
ensure_file_copy() {
local target="$1" source="$2"
if [[ ! -f "$source" ]]; then
warn "source file missing: $source"
return 1
fi
# Symlink guard: NEVER cp through a symlink. A legacy install left some
# ~/Library/LaunchAgents/*.plist as symlinks INTO the repo (e.g.
# workspace-watcher). macOS cp follows the link and would write the rendered,
# /Users/mukuls-substituted content back through it into the committed
# template — corrupting the portable template and dirtying the tree. Replace
# any such symlink with a real file. (-e is false for a dangling symlink, so
# test -L explicitly.)
if [[ -L "$target" ]]; then
note "REPLACE symlink $target → real rendered file (was → $(readlink "$target"))"
if [[ $APPLY -eq 1 ]]; then
rm -f "$target"
fi
fi
if [[ -f "$target" ]] && cmp -s "$source" "$target"; then
note "OK $target (matches $source)"
return 0
fi
if [[ -f "$target" ]]; then
local backup="${target}.bak-${TS}"
note "UPDATE $target (backup → $backup)"
if [[ $APPLY -eq 1 ]]; then
cp "$target" "$backup"
cp "$source" "$target"
fi
else
note "NEW $target (← $source)"
if [[ $APPLY -eq 1 ]]; then
mkdir -p "$(dirname "$target")"
cp "$source" "$target"
fi
fi
}
# launchctl_reload <label> <plist_path>
launchctl_reload() {
local label="$1" plist="$2"
if [[ ! -f "$plist" ]]; then
warn "plist missing for $label at $plist"
return 1
fi
if [[ $APPLY -eq 1 ]]; then
launchctl bootout "gui/$UID/$label" 2>/dev/null || true
launchctl bootstrap "gui/$UID" "$plist" 2>/dev/null || \
launchctl load "$plist"
note "reloaded $label"
else
note "would reload $label (bootout + bootstrap $plist)"
fi
}
# --- 0. Preflight (assistant-doctor) ----------------------------------------
# Run the doctor BEFORE any mutation so a missing prerequisite is a clear,
# actionable error rather than a half-wired system. CORE failures (python, repo,
# git, cmux) abort an interactive --apply; optional-feature failures (Slack /
# warm session) only warn. Under the pulse self-update (ASSISTANT_SELF_UPDATE=1)
# we report-only and never block — the running box already passed core once.
log "[0] Preflight — assistant-doctor"
DOCTOR="$REPO_ROOT/bin/assistant-doctor.py"
DOCTOR_PY="$(command -v python3 || echo /usr/bin/python3)"
if [[ -f "$DOCTOR" ]]; then
if "$DOCTOR_PY" "$DOCTOR" --only core; then
note "core preflight passed"
else
if [[ "${ASSISTANT_SELF_UPDATE:-0}" == "1" ]]; then
warn "core preflight FAILED (self-update: report-only, not blocking)"
elif [[ $APPLY -eq 1 ]]; then
warn "core preflight FAILED — aborting --apply. Fix the ↳ items above and re-run."
exit 1
else
warn "core preflight FAILED (dry-run: not blocking; --apply would abort)"
fi
fi
else
warn "assistant-doctor.py not found at $DOCTOR — skipping preflight"
fi
log ""
# --- 1. Symlink code into ~/.claude/ ----------------------------------------
log "[1/5] Symlinking code into ~/.claude/"
ensure_symlink "$HOME_DIR/.claude/bin" "$REPO_ROOT/bin"
ensure_symlink "$HOME_DIR/.local/bin/assistant-llm" \
"$REPO_ROOT/bin/assistant-llm.py"
# Decommission legacy spawn-prompts links from the old LLM-Assistant era
# (prompt-assistant-agent.md, prompt-triage-agent.md). The mechanical
# pulse.py orchestrator does not need these — the only prompt it loads is
# observer-batch-prompt.md, which it reads directly from $REPO_ROOT/prompts/.
for legacy_prompt in \
"$HOME_DIR/.claude/spawn-prompts/prompt-assistant-agent.md" \
"$HOME_DIR/.claude/spawn-prompts/prompt-triage-agent.md"; do
if [[ -L "$legacy_prompt" || -e "$legacy_prompt" ]]; then
note "REMOVE legacy $legacy_prompt (no longer needed; pulse.py reads observer-batch-prompt.md directly)"
if [[ $APPLY -eq 1 ]]; then
rm -f "$legacy_prompt"
fi
fi
done
# Lessons live inside ~/.claude/CLAUDE.md as a `## Lessons` section. CLAUDE.md
# is officially auto-loaded by Claude Code into every session, so any agent
# (this Assistant, an ad-hoc claude session, the per-ws observer subagents) sees the
# rules without explicit injection. Each user maintains their own. The
# curator at bin/assistant-curator.py reads/writes that section.
#
# Decommission legacy lesson stores if present:
# ~/.claude/lessons/ — pre-2026-05-23 location (symlinked into repo)
# ~/.assistant/lessons/ — 2026-05-23 location (JSON sidecar)
for legacy_lessons in "$HOME_DIR/.claude/lessons" "$HOME_DIR/.assistant/lessons"; do
if [[ -e "$legacy_lessons" ]]; then
note "REMOVE legacy $legacy_lessons (lessons now live in ~/.claude/CLAUDE.md)"
if [[ $APPLY -eq 1 ]]; then
rm -rf "$legacy_lessons"
fi
fi
done
ensure_symlink \
"$HOME_DIR/.claude/assistant-operating-guide.md" \
"$REPO_ROOT/docs/assistant-operating-guide.md"
log ""
# --- 2. Symlink skills ------------------------------------------------------
# Skills are SYMLINKED (not copied) into ~/.claude/skills/<name> → the repo's
# skills/<name>/. A copy-based install silently reverted live edits: the pulse
# self-update runs `install.sh --apply` after any pull touching skills/, and
# the copy path overwrote ~/.claude/skills/<name>/ with the repo version,
# clobbering uncommitted in-place edits (this is exactly how the cleanup
# skill's no-close-workspace edit came back on 2026-06-05). Symlinks make the
# repo the single source of truth: a `git pull` is live immediately, with
# nothing to re-copy and nothing to clobber.
#
# Backups of pre-existing live skills go to ~/.claude/skills-backups/, NOT
# ~/.claude/skills/, because Claude Code auto-discovers ANY directory under
# ~/.claude/skills/ as a skill — leaving .bak entries there pollutes the
# registry. (ensure_symlink's default <target>.bak-<ts> would land inside
# ~/.claude/skills/, so we back up by hand here before symlinking.)
log "[2/5] Symlinking skills into ~/.claude/skills/"
mkdir -p "$HOME_DIR/.claude/skills" "$HOME_DIR/.claude/skills-backups"
for skill_dir in "$REPO_ROOT"/skills/*/; do
skill_name="$(basename "$skill_dir")"
target="$HOME_DIR/.claude/skills/$skill_name"
expected="$REPO_ROOT/skills/$skill_name"
# Already the correct symlink — nothing to do.
if [[ -L "$target" ]]; then
current="$(readlink "$target")"
if [[ "$current" == "$expected" ]]; then
note "OK $target → $expected"
else
note "FIX $target → was: $current now: $expected"
if [[ $APPLY -eq 1 ]]; then
rm "$target"
ln -s "$expected" "$target"
fi
fi
continue
fi
# A real directory (copy from a prior copy-based install, or live edits).
# Back it up out of the skills tree before replacing with a symlink so we
# never destroy uncommitted work and never pollute the skill registry.
if [[ -e "$target" ]]; then
backup="$HOME_DIR/.claude/skills-backups/${skill_name}.bak-${TS}"
if [[ -d "$target" ]] && diff -rq "$expected" "$target" >/dev/null 2>&1; then
note "MIGRATE $target (copy matches repo) → symlink"
else
warn "MIGRATE $target differs from repo — backing up to $backup before symlinking"
note " (any uncommitted live edits are preserved in the backup;"
note " bring them into the repo with: install.sh --pull-skills)"
fi
if [[ $APPLY -eq 1 ]]; then
mv "$target" "$backup"
ln -s "$expected" "$target"
fi
continue
fi
# Nothing there — fresh symlink.
note "NEW $target → $expected (symlink)"
if [[ $APPLY -eq 1 ]]; then
ln -s "$expected" "$target"
fi
done
log ""
# Factory Droid discovers the same repo-owned skills and durable instructions
# through its native paths.
ensure_symlink "$HOME_DIR/.factory/skills" "$HOME_DIR/.claude/skills"
ensure_symlink "$HOME_DIR/.factory/AGENTS.md" "$HOME_DIR/.claude/CLAUDE.md" || true
log ""
# --- 3. Generate LaunchAgent plists from templates + reload only those that changed ----------
# The committed launchagents/*.plist files are TEMPLATES: they carry four
# machine-independent tokens that we substitute with this box's real values at
# install time, staging the result in a temp dir before copying to
# ~/Library/LaunchAgents/. This replaced an earlier `/Users/<user>/`+sed scheme
# that was a silent no-op (the plists held literal /Users/mukuls, which the sed
# never matched, so every daemon shipped the author's home path and died on any
# other machine — the P1 onboarding bug).
#
# Tokens (see any launchagents/*.plist):
# __HOME__ → this user's home ($HOME_DIR)
# __REPO__ → this checkout ($REPO_ROOT), NOT assumed to be ~/dev/assistant
# __PYTHON__ → an arch-resolved python3 (Apple-Silicon /opt/homebrew vs Intel
# /usr/local vs system /usr/bin) — a literal path would 404 on
# the other arch
# __PATH__ → an arch-aware PATH superset incl. the Homebrew bin + cmux
#
# The filenames are deliberately UNCHANGED (still <label>.plist): self_update.py
# keys plist-change detection + its own-reload-defer guard on the exact string
# `launchagents/<label>.plist`, and its tests hardcode these names. Keeping the
# templates AT those paths means self-update keeps working.
log "[3/5] Generating LaunchAgent plists into ~/Library/LaunchAgents/"
mkdir -p "$HOME_DIR/Library/LaunchAgents"
PLIST_STAGE="$(mktemp -d)"
trap 'rm -rf "$PLIST_STAGE"' EXIT
# Resolve this machine's interpreter + PATH ONCE. Probe for a real python3
# rather than hardcode an arch: Homebrew (arm64 /opt/homebrew, Intel
# /usr/local), then system. Fail loud if none — a daemon with no interpreter is
# worse than a clear error.
BREW_BIN=""
if command -v brew >/dev/null 2>&1; then
BREW_BIN="$(brew --prefix 2>/dev/null)/bin"
fi
PLIST_PYTHON=""
for cand in "$BREW_BIN/python3" /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/bin/python3; do
if [[ -n "$cand" && -x "$cand" ]]; then PLIST_PYTHON="$cand"; break; fi
done
if [[ -z "$PLIST_PYTHON" ]]; then
warn "no python3 found for LaunchAgent plists — install Xcode CLT or Homebrew python3"
PLIST_PYTHON="/usr/bin/python3" # last resort; doctor (phase 0) flags a missing one
fi
# PATH superset: real Homebrew bin (if any) first, then the standard dirs + the
# cmux CLI dir the watchers/comms need.
PLIST_PATH_VALUE="${BREW_BIN:+$BREW_BIN:}/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/Applications/cmux.app/Contents/Resources/bin"
note "plist interpreter: $PLIST_PYTHON"
# Opt-in plists the installer must NEVER copy or load. The single-process
# daemon (com.mukul.assistant-daemon) is additive: it replaces the pulse
# agent, so auto-loading it alongside the legacy pulse timer would run two
# pulse loops at once. It is activated by hand (see the plist's header
# comment). The pulse self-update runs `install.sh --apply` on any
# launchagents/ change, so this skip is what keeps a committed plist from
# auto-starting on the running box.
PLIST_SKIP=(
"com.mukul.assistant-daemon.plist"
# Keel M5 connectors are INDEPENDENT KeepAlive daemons that poll external
# APIs (GitHub, Gmail) outside the pulse budget. The installer copies their
# plists but NEVER loads them: load-bearing because the pulse self-update
# re-runs install.sh, and an auto-load would start a network daemon (and,
# for Gmail, begin OAuth refreshes) behind Mukul's back. He runs
# `launchctl load …` by hand once the connector is configured.
"com.assistant.connector-github.plist"
"com.assistant.connector-gmail.plist"
# M5 wave-2 connectors — same INDEPENDENT KeepAlive daemon contract as
# wave-1 (copied, never auto-loaded; Mukul runs `launchctl load` by hand
# once each source is configured). KeepAlive={SuccessfulExit:false}, NOT
# KeepAlive=true, so an unconfigured daemon never hot-respawns (F3).
"com.assistant.connector-gcal.plist"
"com.assistant.connector-slack.plist"
# M5 wave-3 Outlook (readonly) mail connector — same INDEPENDENT KeepAlive
# daemon contract (copied, never auto-loaded; Mukul runs `launchctl load` by
# hand once the OAuth token cache is seeded). KeepAlive={SuccessfulExit:false}
# so an unconfigured daemon never hot-respawns (F3).
"com.assistant.connector-outlook.plist"
# The machine-config sync daemon PUSHES local config drift to a remote. Copied
# by the loop above (D8: copy-but-not-load) but LOADED only by step 8, and step
# 8 activates it ONLY behind an explicit interactive opt-in + a durable marker
# — so a non-interactive `install.sh --apply` (the pulse self-update path)
# NEVER starts the config-pushing daemon. RunAtLoad: full-auto once activated.
"com.assistant.machine-config-sync.plist"
)
# ── Daemon tiers ────────────────────────────────────────────────────────────
# CORE (everything not listed below) is loaded by default — the fleet loop +
# its dashboard: pulse, world-scanner, session-context-watcher, assistant-page,
# todo-server. There is no product without these.
#
# FEATURE daemons are always COPIED (so a later hand-load / --with flag works
# from the canonical path) but loaded ONLY when the user opts in. Each solves a
# problem not every user has, so none is forced on a fresh install:
# memory-sync-pull cross-machine memory sync → --with-memory
# workspace-watcher auto-resume crashed cmux ws → --with-crash-resume
# assistant-comms Slack comms (needs token + assistant-comms-setup.sh)
# slack-reactor Slack emoji→todo (needs SLACK_APP_TOKEN/SLACK_BOT_TOKEN)
# comms/slack-reactor are never auto-loaded even with a flag — they crash-loop
# without their tokens, so they activate by hand after setup. memory &
# crash-resume DO auto-load when their flag is passed.
#
# feature_of <plist-base> → prints the feature name, or "" if it's core.
feature_of() {
case "$1" in
com.assistant.memory-sync-pull.plist) echo "memory" ;;
com.assistant.workspace-watcher.plist) echo "crash-resume" ;;
com.assistant.assistant-comms.plist) echo "comms" ;;
com.assistant.slack-reactor.plist) echo "slack-reactor" ;;
*) echo "" ;;
esac
}
# feature_enabled <feature> → 0 (load it) / 1 (copy-no-load). comms &
# slack-reactor are ALWAYS copy-no-load (token-gated); memory & crash-resume
# load only when their --with flag was passed.
feature_enabled() {
case "$1" in
memory) [[ $WITH_MEMORY -eq 1 ]] ;;
crash-resume) [[ $WITH_CRASH_RESUME -eq 1 ]] ;;
*) return 1 ;;
esac
}
declare -a CHANGED_LABELS
for plist in "$REPO_ROOT"/launchagents/*.plist; do
base="$(basename "$plist")"
label="$(basename "$plist" .plist)"
staged="$PLIST_STAGE/$base"
target="$HOME_DIR/Library/LaunchAgents/$base"
skip=0
for skip_base in "${PLIST_SKIP[@]}"; do
[[ "$base" == "$skip_base" ]] && skip=1 && break
done
# Tier: CORE loads; a FEATURE daemon loads only if opted in, else copy-no-load.
copy_no_load=0
feat="$(feature_of "$base")"
if [[ -n "$feat" ]]; then
if feature_enabled "$feat"; then
note "FEATURE $base — enabled (flag / prompt / remembered / already-running) → will load"
else
copy_no_load=1
case "$feat" in
memory|crash-resume)
note "FEATURE $base — not enabled (copied, NOT loaded; enable with --with-$feat)" ;;
*) # comms / slack-reactor: token-gated, hand-loaded after setup
note "FEATURE $base — token-gated (copied, NOT loaded; hand-load after setup — see ONBOARDING.md)" ;;
esac
fi
fi
# Substitute the four machine tokens with this box's real values (the
# __TOKEN__ template scheme — every committed plist uses it). A surviving
# __TOKEN__ is a loud plutil/launchd failure, not a silent wrong-path — the
# opposite of the old /Users/<user>/ no-op sed this replaced.
sed -e "s|__PYTHON__|$PLIST_PYTHON|g" \
-e "s|__REPO__|$REPO_ROOT|g" \
-e "s|__HOME__|$HOME_DIR|g" \
-e "s|__PATH__|$PLIST_PATH_VALUE|g" \
"$plist" > "$staged"
# A daemon that must NOT auto-load: either a PLIST_SKIP entry (connectors,
# config-sync, the legacy single-process daemon) OR a FEATURE daemon the user
# didn't opt into (copy_no_load). Both are still COPIED — D8: ALWAYS stage +
# copy even a not-loaded plist so it LANDS in ~/Library/LaunchAgents and the
# documented `launchctl load …` / hand-load succeeds from the canonical path.
# The old code `continue`d BEFORE the copy, so a skipped plist never landed
# and the manual load failed. A not-loaded plist is placed but NOT appended to
# CHANGED_LABELS, so Section 5 never reloads it (the never-auto-load contract).
no_load=0
[[ $skip -eq 1 || $copy_no_load -eq 1 ]] && no_load=1
# A symlinked target (legacy install pointing into the repo) must be turned
# into a real file regardless of content — cmp reads THROUGH the link, so
# never trust an "OK match" on a symlink. Force the ensure_file_copy path.
if [[ ! -L "$target" && -f "$target" ]] && cmp -s "$staged" "$target"; then
if [[ $no_load -eq 1 ]]; then
note "OK $target (matches repo; not auto-loaded — activate by hand)"
else
note "OK $target (matches repo, no reload needed)"
fi
else
ensure_file_copy "$target" "$staged"
if [[ $no_load -eq 1 ]]; then
note "COPIED $base but NOT loaded (activate by hand — see plist header / ONBOARDING.md)"
else
CHANGED_LABELS+=("$label")
fi
fi
done
# Tear down legacy plists (renames + removed services).
# - com.mukuls.triage-pulse → com.assistant.assistant-pulse (2026-05-23 rename)
# - com.mukuls.{assistant-pulse,assistant-page,assistant-todo-server,
# session-context-watcher,world-scanner} → com.assistant.* (2026-05-23 namespacing)
# Leaving any of these loaded would mean two daemons running the same job in
# parallel after install. Always unload + remove on apply.
LEGACY_LABELS=(
"com.mukuls.triage-pulse"
"com.mukuls.assistant-pulse"
"com.mukuls.assistant-page"
"com.mukuls.assistant-todo-server"
"com.mukuls.session-context-watcher"
"com.mukuls.world-scanner"
)
for legacy in "${LEGACY_LABELS[@]}"; do
legacy_plist="$HOME_DIR/Library/LaunchAgents/${legacy}.plist"
if [[ -f "$legacy_plist" ]]; then
note "REMOVE legacy LaunchAgent $legacy (renamed → com.assistant.*)"
if [[ $APPLY -eq 1 ]]; then
launchctl bootout "gui/$UID/$legacy" 2>/dev/null || true
rm "$legacy_plist"
fi
fi
done
log ""
# --- 3b. Ensure the Assistant log dir exists --------------------------------
# Every Assistant LaunchAgent writes its launchd stdout/stderr capture (and the
# two watchers write their app-logs) into ~/.assistant/logs/. launchd will not
# create a missing StandardOutPath parent, so make sure the dir exists.
log "[3b] Ensuring ~/.assistant/logs/"
mkdir -p "$HOME_DIR/.assistant/logs"
note "ensured $HOME_DIR/.assistant/logs/"
ensure_file_copy "$HOME_DIR/.assistant/droid-glm-settings.json" \
"$REPO_ROOT/config/droid-glm-settings.json"
if [[ $APPLY -eq 1 ]]; then
# Factory-optional: must never abort the installer under `set -euo pipefail`
# and skip the Claude core / launchd reload below. Guard the pipeline.
python3 "$REPO_ROOT/install/patch-factory-settings.py" \
"$HOME_DIR/.factory/settings.json" \
"$REPO_ROOT/config/droid-glm-settings.json" 2>&1 | sed 's/^/ /' \
|| warn "patch-factory-settings failed (continuing — Factory defaults not applied)"
else
note "would set Factory interactive defaults to GLM-5.2/high autonomy"
fi
log ""
# --- 4. cmux session-restore (vendored) -------------------------------------
# Three layers that make Claude and Factory Droid panes survive restart/reboot:
# Layer 1+2 hooks/cmux-auto-resume.py + cmux-session-ledger.py
# → symlinked into ~/.claude/hooks/ (a MIXED dir — symlink the
# files individually, never the directory)
# Layer 3 bin/cmux-restore-sessions.py → ~/.local/bin/cmux-restore-sessions
# settings install/patch-settings.py registers the SessionStart/SessionEnd
# hook commands in Claude settings and Factory hooks (idempotent)
# Vendored from the former elitecoder/cmux-session-restore repo so a single
# `assistant install --apply` rebuilds all three layers from git on any machine
# — claude.tgz-style home backups only capture ~/.claude and silently drop the
# ~/.local/bin CLI, leaving a deceptive half-working state.
log "[4/5] Wiring cmux session-restore (hooks + CLI + settings)"
ensure_symlink "$HOME_DIR/.claude/hooks/cmux-auto-resume.py" "$REPO_ROOT/hooks/cmux-auto-resume.py"
ensure_symlink "$HOME_DIR/.claude/hooks/cmux-session-ledger.py" "$REPO_ROOT/hooks/cmux-session-ledger.py"
ensure_symlink "$HOME_DIR/.local/bin/cmux-restore-sessions" "$REPO_ROOT/bin/cmux-restore-sessions.py"
if [[ $APPLY -eq 1 ]]; then
CMUX_CLI="/Applications/cmux.app/Contents/Resources/bin/cmux"
if [[ -x "$CMUX_CLI" ]]; then
# Factory-optional: an old cmux without factory support, or a droid-less
# box, must not abort the installer before the Claude settings patch and
# launchd reload below.
"$CMUX_CLI" hooks factory install --yes 2>&1 | sed 's/^/ /' \
|| warn "cmux Factory hooks install failed (continuing — droid resume may be unbound)"
else
warn "cmux CLI missing; Factory lifecycle hooks were not installed"
fi
python3 "$REPO_ROOT/install/patch-settings.py" \
"$HOME_DIR/.claude/settings.json" "$HOME_DIR/.factory/hooks.json" \
2>&1 | sed 's/^/ /' \
|| warn "patch-settings failed (continuing — session-restore hooks may be incomplete)"
else
note "would install cmux Factory hooks and patch Claude + Factory lifecycle hooks"
fi
log ""
# --- 5. Reload only the daemons whose plists actually changed --------------
# When invoked by the pulse's own self-update (ASSISTANT_SELF_UPDATE=1), never
# reload the pulse's OWN plist: launchctl bootout would SIGTERM the running
# pulse.py mid-update and abort the install. The new plist still got copied;
# it applies on the next manual install or reboot. Plist changes are rare;
# code changes (symlinked bin/, no reload needed) are the common case.
SELF_PLIST_LABEL="com.assistant.assistant-pulse"
log "[5/5] Reloading launchd agents that changed"
if [[ ${#CHANGED_LABELS[@]} -eq 0 ]]; then
note "no plists changed — nothing to reload"
else
for label in "${CHANGED_LABELS[@]}"; do
if [[ "${ASSISTANT_SELF_UPDATE:-0}" == "1" && "$label" == "$SELF_PLIST_LABEL" ]]; then
note "skip reload of $label (self-update can't bootout its own pulse; applies on next reboot/manual install)"
continue
fi
launchctl_reload "$label" "$HOME_DIR/Library/LaunchAgents/${label}.plist"
done
fi
log ""
# --- 6. cmux-watcher LaunchAgent (opt-in: written, NEVER auto-loaded) -------
# The cmux-watcher taps `cmux events --category agent --reconnect` and drops
# workspace signals into ~/.assistant/inbox within seconds. Per the global
# CLAUDE.md lesson ("Always ask before running launchctl load"), this installer
# WRITES the plist but never loads it — it prints the load command for Mukul to
# run by hand.
log "[6/6] Writing cmux-watcher LaunchAgent plist (NOT loaded)"
WATCHER_PLIST="$HOME_DIR/Library/LaunchAgents/com.mukul.assistant-cmux-watcher.plist"
# Reuse the arch-resolved interpreter from Section 3 (falls back if this section
# is ever reached standalone).
WATCHER_PY="${PLIST_PYTHON:-/usr/bin/python3}"
WATCHER_PLIST_BODY="$(cat <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.mukul.assistant-cmux-watcher</string>
<key>ProgramArguments</key>
<array>
<string>$WATCHER_PY</string>
<string>$REPO_ROOT/bin/cmux-watcher.py</string>
</array>
<key>KeepAlive</key><true/>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>$HOME_DIR/.assistant/cmux-watcher.log</string>
<key>StandardErrorPath</key><string>$HOME_DIR/.assistant/cmux-watcher-error.log</string>
</dict>
</plist>
PLIST
)"
if [[ $APPLY -eq 1 ]]; then
printf '%s\n' "$WATCHER_PLIST_BODY" > "$WATCHER_PLIST"
note "wrote $WATCHER_PLIST"
else
note "would write $WATCHER_PLIST"
fi
note "cmux-watcher is OPT-IN — NOT loaded automatically."
note "To activate it yourself:"
note " launchctl load $WATCHER_PLIST"
log ""
# --- 7. Memory setup -----------------------------------------------------------
# Two paths:
# A) Owner machine (Mukul): clone the private mukul-memory repo and run its
# install.sh to sync all memories, lessons, and Obsidian notes across machines.
# B) Other user: set up local-only memory (Mem0 with local fastembed, no cross-machine
# sync). Lessons stay in ~/.claude/CLAUDE.md; semantic memory lives at
# ~/.assistant/mem0/ on this machine only.
#
# The installer asks interactively only when --apply is set; in dry-run it explains
# what each path would do.
log "[7/8] Memory setup"
MEMORY_CONFIG="$HOME_DIR/.assistant/memory-repo-config.json"
if [[ -f "$MEMORY_CONFIG" ]]; then
note "OK memory already configured ($MEMORY_CONFIG exists)"
else
if [[ $APPLY -eq 0 ]]; then
note "(dry-run) Would ask: is this the owner's machine or a new user's?"
note " Owner path: clone git@github.com:OneAdobe/mukul-memory + run scripts/install.sh"
note " Other user path: initialize local-only memory at ~/.assistant/mem0/"
else
log ""
# Non-interactive (self-update, CI, pipe): skip memory setup silently.
# Memory config is a one-time human decision — auto-update must never
# block waiting for input or overwrite an existing choice.
if [[ ! -t 0 ]]; then
note "non-interactive run — skipping memory setup (run install.sh --apply manually to configure)"
log ""
# Jump to summary by skipping the case block
MEM_CHOICE=3
else
log "Memory is not configured on this machine. Choose a setup:"
log " 1) Owner machine (Mukul) — sync memories, lessons, Obsidian notes from the private mukul-memory repo"
log " 2) New user — set up local-only memory (no cross-machine sync)"
log " 3) Skip — set up memory manually later"
log ""
read -r -p "Choice [1/2/3]: " MEM_CHOICE
fi # end interactive block
case "$MEM_CHOICE" in
1)
MEMORY_REPO_DIR="$HOME_DIR/dev/mukul-memory"
if [[ -d "$MEMORY_REPO_DIR/.git" ]]; then
note "mukul-memory already cloned at $MEMORY_REPO_DIR"
else
log " Cloning mukul-memory…"
git clone "git@github.com:OneAdobe/mukul-memory.git" "$MEMORY_REPO_DIR" \
&& note "cloned to $MEMORY_REPO_DIR" \
|| { warn "clone failed — check your git@github.com (work) SSH key"; }
fi
if [[ -d "$MEMORY_REPO_DIR/.git" ]]; then
log " Running memory install (sync-pull: lessons + mem0 + Obsidian)…"
bash "$MEMORY_REPO_DIR/scripts/install.sh" \
&& note "memory synced from mukul-memory repo" \
|| warn "memory install had errors — check $MEMORY_REPO_DIR/scripts/install.sh"
fi
;;
2)
log " Initializing local-only memory…"
mkdir -p "$HOME_DIR/.assistant/mem0"
# Write a minimal config that disables cross-machine sync
cat > "$MEMORY_CONFIG" <<LOCAL_CFG
{
"memory_repo": null,
"sync": {
"push_on_lesson_confirm": false,
"push_on_memory_add": false,
"pull_interval_seconds": 0
},
"stores": {
"lessons_file": null,
"memories_file": null,
"chroma_dir": "~/.assistant/mem0/chroma",
"claude_md": "~/.claude/CLAUDE.md"
}
}
LOCAL_CFG
note "wrote $MEMORY_CONFIG (local-only mode)"
note "Semantic memory will build up locally as you use the assistant."
note "To enable cross-machine sync later: set up a private git repo,"
note "then update $MEMORY_CONFIG with the repo URL."
;;
3)
note "Skipped. Run install.sh --apply again when ready to configure memory."
;;
*)
warn "Unknown choice '$MEM_CHOICE' — skipping memory setup."
;;
esac
fi
fi
log ""
# --- 8. Machine config (Droid/Claude dotfiles) ------------------------------
# Mirrors the memory model: the canonical Factory + Claude machine config lives
# in the private machine-config repo, which ships its own scripts/install.sh
# (apply) plus sync-pull/sync-push. Here we clone it (if needed) and project it
# onto this box; the com.assistant.machine-config-sync LaunchAgent (copied in
# step 3) then keeps it in sync hourly. Non-fatal if offline / SSH key missing.
log "[8/8] Machine config (Factory/Claude dotfiles)"
MC_REPO_DIR="$HOME_DIR/dev/machine-config"
MC_REMOTE="git@github-personal:elitecoder/machine-config.git"
MC_SYNC_PLIST="com.assistant.machine-config-sync"
MC_MARKER="$HOME_DIR/.assistant/machine-config-configured"
# OPT-IN, exactly like [7] memory: this clones a repo, symlinks dotfiles over the
# box's live config, and loads a daemon that PUSHES config drift to a shared
# remote — so it must NEVER activate itself. The gates (marker → skip;
# non-interactive → skip; else prompt) guarantee a non-interactive
# `install.sh --apply` (the pulse self-update path) can never headlessly opt a
# box in. Idempotent: once the marker exists we leave everything as-is.
if [[ -f "$MC_MARKER" ]]; then
note "OK machine-config sync already opted in ($MC_MARKER) — leaving as-is"
elif [[ $APPLY -eq 0 ]]; then
note "(dry-run) would ASK whether to opt into machine-config sync (clone + symlink dotfiles + hourly push/pull daemon). Not opted in yet."
elif [[ ! -t 0 || "${ASSISTANT_SELF_UPDATE:-0}" == "1" ]]; then
# Non-interactive OR the pulse self-update (which exports ASSISTANT_SELF_UPDATE=1):
# never prompt. Checking the env var too — not just the tty — makes the skip
# deterministic even if a future deploy path hands the self-update a pty.
note "non-interactive / self-update — NOT opting into machine-config sync (run install.sh --apply by hand to enable)"
else
log ""
log "Set up machine-config sync?"
log " This clones the private machine-config repo, SYMLINKS Factory/Claude"
log " dotfiles onto this box, and loads a daemon that PUSHES local config"
log " drift to a shared remote + pulls other machines' changes hourly."
read -r -p "Opt in? [y/N]: " MC_CHOICE || MC_CHOICE=""
if [[ "$MC_CHOICE" =~ ^[Yy] ]]; then
if [[ ! -d "$MC_REPO_DIR/.git" ]]; then
log " Cloning machine-config…"
git clone "$MC_REMOTE" "$MC_REPO_DIR" \
&& note "cloned to $MC_REPO_DIR" \
|| warn "clone failed — check your github-personal SSH key (skipping)"
else
note "machine-config already cloned at $MC_REPO_DIR"
fi
if [[ -d "$MC_REPO_DIR/.git" ]]; then
log " Applying machine config (symlink Factory/Claude config, reconcile crons)…"
if bash "$MC_REPO_DIR/scripts/install.sh" 2>&1 | sed 's/^/ /'; then
note "machine config applied (originals backed up to *.bak-* where present)"