forked from coalesce-labs/catalyst
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup-catalyst.sh
More file actions
executable file
·2628 lines (2264 loc) · 83.9 KB
/
Copy pathsetup-catalyst.sh
File metadata and controls
executable file
·2628 lines (2264 loc) · 83.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
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
#!/bin/bash
# setup-catalyst.sh - Complete Catalyst setup in one command
# Usage: curl -fsSL https://raw.githubusercontent.com/coalesce-labs/catalyst/main/setup-catalyst.sh | bash
# OR ./setup-catalyst.sh [--non-interactive|--defaults]
# Headless (CI/SSH/cron): --non-interactive or CATALYST_AUTONOMOUS=1 — prompts use
# defaults, integrations configure only from discoverable tokens (LINEAR_API_TOKEN, etc.)
set -e
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
PROJECT_DIR=""
PROJECT_KEY=""
ORG_NAME=""
REPO_NAME=""
ORG_ROOT=""
THOUGHTS_REPO=""
WORKTREE_BASE=""
USER_NAME=""
NON_INTERACTIVE=0
#
# Utility functions
#
print_header() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
}
print_success() {
echo -e "${GREEN}✓ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠ $1${NC}"
}
print_error() {
echo -e "${RED}✗ $1${NC}"
}
# True when /dev/tty can actually be opened (existence alone is not enough:
# with no controlling tty, open(2) fails ENXIO — "Device not configured").
# The subshell absorbs the failed open so `set -e` does not kill the script.
can_open_tty() {
(: </dev/tty) 2>/dev/null
}
# Parse CLI flags. CATALYST_AUTONOMOUS is the project-wide headless signal
# (same contract as plugins/dev/scripts/check-project-setup.sh).
parse_args() {
if [[ -n ${CATALYST_AUTONOMOUS:-} ]]; then
NON_INTERACTIVE=1
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--non-interactive | --defaults)
NON_INTERACTIVE=1
shift
;;
-h | --help)
echo "Usage: setup-catalyst.sh [--non-interactive|--defaults]"
exit 0
;;
*)
print_error "Unknown option: $1"
echo "Usage: setup-catalyst.sh [--non-interactive|--defaults]"
exit 1
;;
esac
done
}
ask_yes_no() {
local prompt="$1"
local default="${2:-y}"
local ni_answer="${3:-$default}"
local suffix="[y/N]"
[[ $default == "y" ]] && suffix="[Y/n]"
local REPLY
# Non-interactive mode (HEAD/sibling feature): answer with ni_answer
# without touching stdin. CTL-1214 (PATH-B #5): when check_prerequisites is
# auto-installing a CRITICAL tool headlessly it sets CATALYST_NI_AUTOINSTALL=1
# to force-accept the install offer — otherwise the optional-style ni_answer
# of "n" aborts an autonomous catalyst-join on the HumanLayer/gh prereq gate.
# The default ni_answer is preserved for every other prompt (tests assert the
# install offers still decline in plain NI mode).
if [[ ${NON_INTERACTIVE:-0} -eq 1 ]]; then
if [[ ${CATALYST_NI_AUTOINSTALL:-0} -eq 1 ]]; then ni_answer="y"; fi
echo "$prompt $suffix → ${ni_answer} (non-interactive)" >&2
[[ $ni_answer == "y" ]]
return
fi
# CTL-843: full-line read — `read -n 1` left the trailing newline in stdin,
# which bled into the next prompt and produced garbage config values. A
# full-line read keeps consecutive prompts aligned for both interactive
# terminals and piped stdin; EOF (read rc!=0) falls back to the default.
read -r -p "$prompt $suffix " REPLY || REPLY=""
if [[ -z $REPLY ]]; then
[[ $default == "y" ]]
else
[[ $REPLY =~ ^[Yy] ]]
fi
}
# prompt_value <prompt> <default> — echo the answer; in non-interactive mode
# (or on EOF) echo the default without consuming stdin.
prompt_value() {
local prompt="$1"
local default="${2:-}"
local reply=""
if [[ ${NON_INTERACTIVE:-0} -eq 1 ]]; then
echo "$prompt [${default}] → ${default} (non-interactive)" >&2
# printf, not echo: a value of exactly -n/-e/-E is an echo option and would
# emit nothing, silently blanking that field (e.g. an explicit invalid
# deployment mode would round-trip to "" and read as unset instead of the
# recognized:false error). printf '%s' treats the value as data, never a flag.
printf '%s\n' "$default"
return 0
fi
read -p "$prompt " -r reply || reply=""
printf '%s\n' "${reply:-$default}"
}
# Merge a patch object into .catalyst.<section> of a config JSON string (CTL-843).
# The prompt run is authoritative for its own keys ($owned, a JSON array) — stale
# owned keys are deleted — and ALL other keys (e.g. linear.agent) are preserved.
merge_catalyst_section() {
local config="$1" section="$2" patch="$3" owned="$4"
echo "$config" | jq --arg s "$section" --argjson patch "$patch" --argjson owned "$owned" '
.catalyst //= {}
| .catalyst[$s] = (
((.catalyst[$s] // {}) | with_entries(select(.key as $k | $owned | index($k) | not)))
+ $patch
)'
}
# Secret-hygiene primitives (CTL-1203). Inlined for standalone curl-able use.
# The canonical sourceable lib lives at plugins/dev/scripts/lib/secrets-hygiene.sh.
# harden_secrets_dir <dir> — mkdir -p then chmod 700. Idempotent.
harden_secrets_dir() {
local dir="$1"
[[ -n "$dir" ]] || return 1
mkdir -p "$dir" || return 1
chmod 700 "$dir"
}
# ensure_secrets_gitignore <dir> — create/update .gitignore with required lines.
ensure_secrets_gitignore() {
local dir="$1" gi line
gi="${dir}/.gitignore"
mkdir -p "$dir" || return 1
[[ -f "$gi" ]] || : > "$gi"
for line in 'config*.json' '*.env'; do
grep -qxF "$line" "$gi" 2>/dev/null || printf '%s\n' "$line" >> "$gi"
done
}
# write_secret_file <content> <path> — atomic 600 writer (no JSON validation).
write_secret_file() {
local content="$1" path="$2" tmp
tmp="$(mktemp)" || return 1
( umask 077; printf '%s' "$content" > "$tmp" ) || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
mv "$tmp" "$path"
}
# Write the per-project secrets config safely (CTL-843): validate JSON first,
# back up the existing file (timestamped, 0600), then write atomically (CTL-1203).
write_secrets_config() {
local content="$1" config_file="$2" validated tmp
tmp=$(mktemp) || return 1
if ! echo "$content" | jq . >"$tmp" 2>/dev/null; then
rm -f "$tmp"
print_error "Refusing to write invalid JSON to $config_file — existing file left untouched"
return 1
fi
validated="$(cat "$tmp")"
rm -f "$tmp"
if [[ -f $config_file ]]; then
local backup="${config_file}.bak-$(date +%Y%m%d-%H%M%S)"
cp -p "$config_file" "$backup"
chmod 600 "$backup"
print_success "Backed up existing config to $backup"
fi
write_secret_file "$validated" "$config_file"
}
#
# Token discovery and validation functions
#
# Discover existing Linear API token from standard locations
discover_linear_token() {
local token=""
# Check environment variable
if [[ -n ${LINEAR_API_TOKEN-} ]]; then
echo "env" >&2
echo "$LINEAR_API_TOKEN"
return 0
fi
# Check ~/.linear_api_token file
if [[ -f ~/.linear_api_token ]]; then
token=$(cat ~/.linear_api_token | tr -d '[:space:]')
if [[ -n $token ]]; then
echo "file" >&2
echo "$token"
return 0
fi
fi
return 1
}
# Validate Linear API token and extract org/teams info
validate_linear_token() {
local token="$1"
# GraphQL query to get viewer and teams
local query='
{
viewer {
id
name
email
organization {
id
name
urlKey
}
}
teams {
nodes {
id
name
key
}
}
}'
local response
response=$(curl -s -X POST \
-H "Authorization: $token" \
-H "Content-Type: application/json" \
-d "{\"query\":$(echo "$query" | jq -Rs .)}" \
https://api.linear.app/graphql 2>&1)
# Check for errors
if echo "$response" | jq -e '.errors' >/dev/null 2>&1; then
echo '{"valid": false, "error": "Invalid token or API error"}' >&2
return 1
fi
# Extract data
local viewer=$(echo "$response" | jq -r '.data.viewer')
local teams=$(echo "$response" | jq -r '.data.teams.nodes')
if [[ $viewer == "null" ]]; then
echo '{"valid": false, "error": "No user data returned"}' >&2
return 1
fi
# Return validation result
echo "$response" | jq '{
valid: true,
viewer: .data.viewer,
teams: .data.teams.nodes
}'
}
# Fetch workflow states for a Linear team
# Args: $1 = API token, $2 = team key
# Returns JSON array of workflow states with name, type, position
fetch_linear_workflow_states() {
local token="$1"
local team_key="$2"
local query='
{
teams(filter: { key: { eq: "'"$team_key"'" } }) {
nodes {
workflowStates {
nodes {
name
type
position
}
}
}
}
}'
local response
response=$(curl -s -X POST \
-H "Authorization: $token" \
-H "Content-Type: application/json" \
-d "{\"query\":$(echo "$query" | jq -Rs .)}" \
https://api.linear.app/graphql 2>&1)
# Check for errors
if echo "$response" | jq -e '.errors' >/dev/null 2>&1; then
return 1
fi
# Extract workflow states
local states
states=$(echo "$response" | jq -r '.data.teams.nodes[0].workflowStates.nodes // empty')
if [[ -z $states || $states == "null" ]]; then
return 1
fi
echo "$states"
}
# Map Linear workflow states to Catalyst stateMap
# Args: $1 = JSON array of workflow states from fetch_linear_workflow_states
# Returns JSON object matching our stateMap schema
build_state_map_from_linear() {
local states="$1"
# Extract state names by type, sorted by position
# Linear types: triage, backlog, unstarted, started, completed, canceled
local backlog_state unstarted_state started_states review_state completed_state canceled_state
backlog_state=$(echo "$states" | jq -r '[.[] | select(.type == "backlog")] | sort_by(.position) | .[0].name // empty')
unstarted_state=$(echo "$states" | jq -r '[.[] | select(.type == "unstarted")] | sort_by(.position) | .[0].name // empty')
completed_state=$(echo "$states" | jq -r '[.[] | select(.type == "completed")] | sort_by(.position) | .[0].name // empty')
canceled_state=$(echo "$states" | jq -r '[.[] | select(.type == "cancelled" or .type == "canceled")] | sort_by(.position) | .[0].name // empty')
# For "started" type, there may be multiple states (e.g., "In Progress", "In Review")
# Try to find one with "review" in the name for our inReview key
local default_started
default_started=$(echo "$states" | jq -r '[.[] | select(.type == "started")] | sort_by(.position) | .[0].name // empty')
review_state=$(echo "$states" | jq -r '[.[] | select(.type == "started") | select(.name | test("review"; "i"))] | .[0].name // empty')
# If no explicit review state found, use the last started state (highest position)
if [[ -z $review_state ]]; then
local last_started
last_started=$(echo "$states" | jq -r '[.[] | select(.type == "started")] | sort_by(.position) | last.name // empty')
# Only use last_started as review if there are multiple started states
local started_count
started_count=$(echo "$states" | jq '[.[] | select(.type == "started")] | length')
if [[ $started_count -gt 1 ]]; then
review_state="$last_started"
else
review_state="$default_started"
fi
fi
# If no triage state but we need a backlog fallback
if [[ -z $backlog_state ]]; then
# Check for triage state as fallback
backlog_state=$(echo "$states" | jq -r '[.[] | select(.type == "triage")] | sort_by(.position) | .[0].name // empty')
fi
# Build the stateMap JSON
jq -n \
--arg backlog "${backlog_state:-Backlog}" \
--arg todo "${unstarted_state:-Todo}" \
--arg research "${default_started:-In Progress}" \
--arg planning "${default_started:-In Progress}" \
--arg inProgress "${default_started:-In Progress}" \
--arg inReview "${review_state:-In Review}" \
--arg done "${completed_state:-Done}" \
--arg canceled "${canceled_state:-Canceled}" \
'{
backlog: $backlog,
todo: $todo,
research: $research,
planning: $planning,
inProgress: $inProgress,
inReview: $inReview,
done: $done,
canceled: $canceled
}'
}
# Update .catalyst/config.json with real Linear workflow states
# Called after Linear integration is configured in secrets
update_config_with_linear_states() {
local config_file="${PROJECT_DIR}/.catalyst/config.json"
# Backward compat: fall back to .claude/ if .catalyst/ doesn't exist yet
if [[ ! -f $config_file && -f "${PROJECT_DIR}/.claude/config.json" ]]; then
config_file="${PROJECT_DIR}/.claude/config.json"
fi
local secrets_file="$HOME/.config/catalyst/config-${PROJECT_KEY}.json"
# Need both files to exist
if [[ ! -f $config_file ]] || [[ ! -f $secrets_file ]]; then
return 0
fi
# Get token and team key from secrets
local token team_key
token=$(jq -r '.catalyst.linear.apiToken // empty' "$secrets_file" 2>/dev/null)
team_key=$(jq -r '.catalyst.linear.teamKey // empty' "$secrets_file" 2>/dev/null)
# Fall back to project config for team key
if [[ -z $team_key ]]; then
team_key=$(jq -r '.catalyst.linear.teamKey // empty' "$config_file" 2>/dev/null)
fi
if [[ -z $token ]] || [[ -z $team_key ]]; then
return 0
fi
echo ""
echo "🔍 Fetching workflow states from Linear for team ${team_key}..."
local states
if states=$(fetch_linear_workflow_states "$token" "$team_key"); then
local state_map
state_map=$(build_state_map_from_linear "$states")
if [[ -n $state_map ]]; then
# Update the project config with real states
local updated_config
updated_config=$(jq --argjson stateMap "$state_map" '.catalyst.linear.stateMap = $stateMap' "$config_file")
echo "$updated_config" | jq . >"$config_file"
echo ""
echo "✓ Updated config.json with actual Linear workflow states:"
echo "$state_map" | jq -r 'to_entries[] | " \(.key): \(.value)"'
echo ""
else
print_warning "Could not build state map from Linear API response. Using defaults."
fi
else
print_warning "Could not fetch workflow states from Linear API. Using defaults."
echo " You can customize later in .catalyst/config.json → catalyst.linear.stateMap"
fi
}
# Ensure the execution-core Linear-state contract (CTL-564). A thin wrapper:
# invokes setup-execution-core-states.sh for every --full repo so the contract
# states, collapse stateMap, and registry entry are provisioned regardless of
# dispatchMode (CTL-722: the stateMap write is idempotent — the states script
# preserves a template-default or user-customised map). A non-zero exit (e.g. a
# Linear-permission failure) is tolerated (|| true) so it never aborts setup.
setup_execution_core_states() {
# Resolve config the same way update_config_with_linear_states does:
# .catalyst/ with a .claude/ backward-compat fallback.
local config_file="${PROJECT_DIR}/.catalyst/config.json"
if [[ ! -f $config_file && -f "${PROJECT_DIR}/.claude/config.json" ]]; then
config_file="${PROJECT_DIR}/.claude/config.json"
fi
[[ -f $config_file ]] || return 0
# CTL-722: run the state-contract step for every --full repo, not only
# execution-core ones, so a fresh phase-agents repo provisions the contract
# states + registry entry. The stateMap write is idempotent (the states
# script preserves a template-default or user-customised map).
# Locate the standalone script — installed plugin root or the repo checkout.
local states_script=""
if [[ -n ${CLAUDE_PLUGIN_ROOT:-} && -f "${CLAUDE_PLUGIN_ROOT}/scripts/setup-execution-core-states.sh" ]]; then
states_script="${CLAUDE_PLUGIN_ROOT}/scripts/setup-execution-core-states.sh"
elif [[ -f "plugins/dev/scripts/setup-execution-core-states.sh" ]]; then
states_script="plugins/dev/scripts/setup-execution-core-states.sh"
fi
if [[ -z $states_script ]]; then
print_warning "execution-core repo, but setup-execution-core-states.sh not found — skipping state contract"
return 0
fi
echo ""
echo "🔗 Ensuring execution-core Linear-state contract..."
bash "$states_script" --config "$config_file" || true
}
# Discover existing Sentry auth token
discover_sentry_token() {
local token=""
# Check environment variable
if [[ -n ${SENTRY_AUTH_TOKEN-} ]]; then
echo "env" >&2
echo "$SENTRY_AUTH_TOKEN"
return 0
fi
# Check ~/.sentryclirc file
if [[ -f ~/.sentryclirc ]]; then
token=$(grep -E '^token\s*=' ~/.sentryclirc 2>/dev/null | cut -d'=' -f2 | tr -d '[:space:]' || echo "")
if [[ -n $token ]]; then
echo "file" >&2
echo "$token"
return 0
fi
fi
return 1
}
# Validate Sentry auth token and get org/projects
validate_sentry_token() {
local token="$1"
# Get organizations
local orgs_response
orgs_response=$(curl -s -X GET \
-H "Authorization: Bearer $token" \
https://sentry.io/api/0/organizations/ 2>&1)
# Check if valid JSON and has data
if ! echo "$orgs_response" | jq -e '.' >/dev/null 2>&1; then
echo '{"valid": false, "error": "Invalid response from API"}' >&2
return 1
fi
if echo "$orgs_response" | jq -e '.detail' >/dev/null 2>&1; then
local error=$(echo "$orgs_response" | jq -r '.detail')
echo "{\"valid\": false, \"error\": \"$error\"}" >&2
return 1
fi
# Get first org slug
local org_slug=$(echo "$orgs_response" | jq -r '.[0].slug // empty')
if [[ -z $org_slug ]]; then
echo '{"valid": false, "error": "No organizations found"}' >&2
return 1
fi
# Get projects for first org
local projects_response
projects_response=$(curl -s -X GET \
-H "Authorization: Bearer $token" \
"https://sentry.io/api/0/organizations/$org_slug/projects/" 2>&1)
# Return validation result
jq -n \
--argjson orgs "$orgs_response" \
--argjson projects "$projects_response" \
'{
valid: true,
organizations: $orgs,
projects: $projects
}'
}
#
# Prerequisite functions
#
check_command_exists() {
command -v "$1" &>/dev/null
}
# No-sudo install helpers (CTL-844) ──────────────────────────────────────────
LOCAL_BIN="$HOME/.local/bin"
detect_arch() {
case "$(uname -m)" in
arm64 | aarch64) echo "arm64" ;;
x86_64) echo "amd64" ;;
*) echo "$(uname -m)" ;;
esac
}
detect_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
# Shell rc files to persist PATH lines into, picked by login shell ($SHELL):
# zsh → ~/.zshenv; bash → ~/.bashrc + ~/.profile (interactive + login);
# unknown → all three. Fresh Linux machines default to bash, so writing only
# ~/.zshenv left installed tools invisible in new shells (CTL-844 remediate).
path_rc_files() {
case "${SHELL:-}" in
*zsh) echo "$HOME/.zshenv" ;;
*bash) printf '%s\n' "$HOME/.bashrc" "$HOME/.profile" ;;
*) printf '%s\n' "$HOME/.zshenv" "$HOME/.bashrc" "$HOME/.profile" ;;
esac
}
# Append a PATH export line to each shell rc file exactly once. Idempotent.
persist_path_line() {
local line="$1" rc
while IFS= read -r rc; do
if ! grep -qsF "$line" "$rc" 2>/dev/null; then
printf '\n# Added by catalyst setup (no-sudo tool installs)\n%s\n' "$line" >>"$rc"
fi
done < <(path_rc_files)
}
# Create ~/.local/bin and persist it on PATH via the shell rc files. Idempotent.
# Also persist ~/.local/node/bin: the no-sudo Node install lives at ~/.local/node
# and `npm install -g humanlayer linearis` puts their bins in that node prefix
# (~/.local/node/bin), which is NOT covered by the node/npm/npx symlinks in
# ~/.local/bin — without this line fresh login shells cannot find humanlayer or
# linearis (CTL-1214 / mini-2 onboarding: "PATH must include ~/.local/node/bin").
ensure_local_bin() {
mkdir -p "$LOCAL_BIN"
persist_path_line 'export PATH="$HOME/.local/bin:$PATH"'
persist_path_line 'export PATH="$HOME/.local/node/bin:$PATH"'
export PATH="$LOCAL_BIN:$HOME/.local/node/bin:$PATH"
}
# ─────────────────────────────────────────────────────────────────────────────
check_prerequisites() {
print_header "Checking Prerequisites"
local missing_critical=false
local missing_optional=false
# Platform check — Catalyst is developed and tested on macOS only
if [[ "$(uname -s)" != "Darwin" ]]; then
echo ""
print_warning "Catalyst is built for macOS. Detected platform: $(uname -s)"
echo " Some features (Homebrew installs, open(1), direnv profiles) assume macOS."
echo " You can continue, but some things may not work as expected."
echo ""
if ! ask_yes_no "Continue on unsupported platform?" "n"; then
echo "Setup cancelled."
exit 0
fi
else
print_success "Platform: macOS ($(sw_vers -productVersion 2>/dev/null || echo 'unknown version'))"
fi
# Critical: git (used throughout for repo detection, worktrees, thoughts)
if ! check_command_exists "git"; then
print_error "git not found (required)"
echo " Install git: https://git-scm.com/downloads"
missing_critical=true
else
print_success "git installed"
fi
# Critical: jq (for config manipulation)
if ! check_command_exists "jq"; then
print_warning "jq not found (required for config management)"
offer_install_jq || missing_critical=true
else
print_success "jq installed"
fi
# Critical: sqlite3 (for session store)
if ! check_command_exists "sqlite3"; then
print_error "sqlite3 not found (required for session store)"
echo " sqlite3 ships with macOS. If missing, install via your package manager."
missing_critical=true
else
print_success "sqlite3 installed"
fi
# Critical: node + npm (linearis, humanlayer CLI, agent-browser are npm packages)
if ! check_command_exists "node" || ! check_command_exists "npm"; then
print_warning "node/npm not found (required for linearis + HumanLayer CLI)"
offer_install_node || missing_critical=true
else
print_success "node installed ($(node --version 2>/dev/null))"
fi
# Critical: bun (catalyst-monitor + execution-core daemons require it)
if ! check_command_exists "bun"; then
print_warning "bun not found (required for the daemon stack)"
offer_install_bun || missing_critical=true
else
print_success "bun installed"
fi
# Critical: humanlayer (for thoughts system)
if ! check_command_exists "humanlayer"; then
print_warning "HumanLayer CLI not found (required for thoughts system)"
# CTL-1214 (PATH-B #5): force-accept the install offer in headless mode so
# an autonomous catalyst-join does not abort on this critical prereq. The
# offer helper's own NI default ("n") is preserved for direct/test calls.
CATALYST_NI_AUTOINSTALL=1 offer_install_humanlayer || missing_critical=true
else
print_success "HumanLayer CLI installed"
fi
# gh: required on a cluster NODE for HTTPS git push auth (gh auth git-credential)
# — thoughts sync pushes over HTTPS, mirroring the seed. Optional for a plain
# workstation install. CTL-1214: auto-install it in headless mode so a node
# join is not left without push credentials (mini-2 needed a manual download).
if ! check_command_exists "gh"; then
print_warning "GitHub CLI not found (needed for node HTTPS git auth / thoughts sync)"
if [[ ${NON_INTERACTIVE:-0} -eq 1 ]]; then
CATALYST_NI_AUTOINSTALL=1 offer_install_gh_cli || missing_optional=true
else
offer_install_gh_cli || missing_optional=true
fi
else
print_success "GitHub CLI installed"
fi
# Optional: linearis (for Linear integration)
if ! check_command_exists "linearis"; then
print_warning "Linearis CLI not found (optional, for Linear integration)"
echo " Install: npm install -g linearis"
missing_optional=true
else
# Check version is at least 1.1.0
local linearis_version
linearis_version=$(linearis --version 2>/dev/null | tail -1 | tr -d '[:space:]')
if [[ $linearis_version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
local major minor patch
IFS='.' read -r major minor patch <<<"$linearis_version"
if [ "$major" -lt 1 ] || ([ "$major" -eq 1 ] && [ "$minor" -lt 1 ]); then
print_warning "Linearis CLI version $linearis_version is too old (need >= 1.1.0)"
echo " Update: npm install -g linearis"
missing_optional=true
else
print_success "Linearis CLI installed (v${linearis_version})"
fi
else
print_success "Linearis CLI installed"
fi
fi
# Optional: agent-browser (browser automation)
if check_command_exists "agent-browser"; then
print_success "agent-browser installed"
else
print_warning "agent-browser not found (optional — browser automation)"
echo " Install: npm install -g agent-browser && agent-browser install"
missing_optional=true
fi
if [ "$missing_critical" = true ]; then
print_error "Critical prerequisites missing. Cannot continue."
exit 1
fi
if [ "$missing_optional" = true ]; then
echo ""
print_warning "Some optional tools are missing. You can:"
echo " - Continue setup (you can add integrations later)"
echo " - Exit and install tools manually"
echo ""
if ! ask_yes_no "Continue without optional tools?"; then
echo "Setup cancelled. Install missing tools and re-run this script."
exit 0
fi
fi
echo ""
}
offer_install_node() {
echo ""
echo "node + npm are required (linearis and the HumanLayer CLI install via npm)."
echo ""
if ! ask_yes_no "Install Node.js LTS to ~/.local/node now (no sudo)?"; then
return 1
fi
if command -v brew &>/dev/null; then
if brew install node; then return 0; fi
print_warning "brew install node failed — falling back to no-sudo install"
fi
ensure_local_bin
local os arch version tarball
os=$([[ "$(uname -s)" == "Darwin" ]] && echo "darwin" || echo "linux")
arch=$([[ "$(detect_arch)" == "arm64" ]] && echo "arm64" || echo "x64")
version="${CATALYST_NODE_VERSION:-$(curl -fsSL https://nodejs.org/dist/index.json |
jq -r '[.[] | select(.lts != false)][0].version')}"
[[ -n "$version" && "$version" != "null" ]] || {
print_error "Could not resolve Node LTS version"
return 1
}
tarball="node-${version}-${os}-${arch}.tar.gz"
echo " Downloading ${tarball} ..."
mkdir -p "$HOME/.local"
# Download + extract + verify in a temp dir, then swap — never delete a
# working ~/.local/node until the replacement node executes (CTL-844
# remediate: a partial extract must not clobber a good install).
local tmp
tmp=$(mktemp -d "$HOME/.local/.node-install.XXXXXX")
if curl -fsSL -o "$tmp/$tarball" "https://nodejs.org/dist/${version}/${tarball}" &&
tar -xzf "$tmp/$tarball" -C "$tmp" &&
"$tmp/node-${version}-${os}-${arch}/bin/node" --version >/dev/null 2>&1; then
rm -rf "$HOME/.local/node"
mv "$tmp/node-${version}-${os}-${arch}" "$HOME/.local/node"
rm -rf "$tmp"
ln -sf "$HOME/.local/node/bin/node" "$LOCAL_BIN/node"
ln -sf "$HOME/.local/node/bin/npm" "$LOCAL_BIN/npm"
ln -sf "$HOME/.local/node/bin/npx" "$LOCAL_BIN/npx"
print_success "Node $(node --version 2>/dev/null || echo "$version") installed to ~/.local/node"
return 0
fi
rm -rf "$tmp"
print_error "Node install failed. Manual: https://nodejs.org/en/download"
return 1
}
offer_install_bun() {
echo ""
echo "bun is required (catalyst-monitor + execution-core daemons run on bun)."
echo ""
if ! ask_yes_no "Install bun now via the official installer (no sudo)?"; then
return 1
fi
if curl -fsSL https://bun.sh/install | bash; then
persist_path_line 'export PATH="$HOME/.bun/bin:$PATH"'
export PATH="$HOME/.bun/bin:$PATH"
command -v bun &>/dev/null && {
print_success "bun installed ($(bun --version))"
return 0
}
fi
print_error "bun install failed. Manual: https://bun.sh"
return 1
}
offer_install_humanlayer() {
echo ""
echo "HumanLayer CLI is required for the thoughts system."
echo ""
echo " Install: npm install -g humanlayer"
echo ""
if ! ask_yes_no "Attempt to install via npm now?" "y" "n"; then
print_warning "Skipping HumanLayer installation. Setup cannot continue."
return 1
fi
if ! command -v npm &>/dev/null; then
print_error "npm not found — node/npm must be installed first (see above)."
return 1
fi
# Run the CLI, don't just `command -v` it — a global-npm prefix off PATH
# or a stale shim passes lookup without working (CTL-844 remediate).
if npm install -g humanlayer && humanlayer --version >/dev/null 2>&1; then
print_success "HumanLayer CLI installed ($(humanlayer --version 2>/dev/null || true))"
return 0
fi
print_error "HumanLayer install failed. Manual: npm install -g humanlayer"
return 1
}
offer_install_gh_cli() {
echo ""
echo "GitHub CLI is used for PR automation and Linear/GitHub integration."
echo ""
if ! ask_yes_no "Install GitHub CLI now?" "y" "n"; then
echo " Manual install: https://cli.github.com/"
return 1
fi
if command -v brew &>/dev/null; then
if brew install gh; then return 0; fi
print_warning "brew install gh failed — falling back to no-sudo install"
fi
# No-sudo: release archive → ~/.local/bin (jq is guaranteed installed by now)
ensure_local_bin
local ver os arch ext dir tmp
ver=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest |
jq -r '.tag_name' | sed 's/^v//')
[[ -n "$ver" && "$ver" != "null" ]] || {
print_error "Could not resolve gh version. Manual: https://cli.github.com/"
return 1
}
if [[ "$(uname -s)" == "Darwin" ]]; then os="macOS"; ext="zip"; else os="linux"; ext="tar.gz"; fi
if [[ "$ext" == "zip" ]] && ! command -v unzip &>/dev/null; then
print_error "unzip not found — cannot extract gh archive. Manual: https://cli.github.com/"
return 1
fi
arch=$(detect_arch)
dir="gh_${ver}_${os}_${arch}"
tmp=$(mktemp -d)
if curl -fsSL -o "$tmp/gh.$ext" \
"https://github.com/cli/cli/releases/download/v${ver}/${dir}.${ext}"; then
if [[ "$ext" == "zip" ]]; then
unzip -q "$tmp/gh.$ext" -d "$tmp"
else
tar -xzf "$tmp/gh.$ext" -C "$tmp"
fi
install -m 0755 "$tmp/$dir/bin/gh" "$LOCAL_BIN/gh"
rm -rf "$tmp"
command -v gh &>/dev/null && {
print_success "GitHub CLI installed to $LOCAL_BIN/gh"
return 0
}
fi
rm -rf "$tmp"
print_error "gh install failed. Manual: https://cli.github.com/"
return 1
}
offer_install_jq() {
echo ""
echo "jq is required for config file manipulation."
echo ""
if ask_yes_no "Attempt to install jq now?" "y" "n"; then
if command -v brew &>/dev/null; then
brew install jq
return 0
elif command -v apt-get &>/dev/null; then
sudo apt-get install -y jq
return 0
else
echo " No package manager found — installing official jq binary (no sudo) ..."
ensure_local_bin
local artifact="jq-$(detect_os)-$(detect_arch)"
# Execute the downloaded binary once — chmod + command -v alone
# would bless a corrupted/partial download (CTL-844 remediate).
if curl -fsSL -o "$LOCAL_BIN/jq" \
"https://github.com/jqlang/jq/releases/latest/download/${artifact}" &&
chmod +x "$LOCAL_BIN/jq" &&
"$LOCAL_BIN/jq" --version >/dev/null 2>&1; then
print_success "jq installed to $LOCAL_BIN/jq"
return 0
fi
print_error "Could not auto-install. Install manually: https://jqlang.github.io/jq/"
return 1
fi
fi
return 1
}
#
# Detection functions
#
detect_git_repo() {
print_header "Detecting Git Repository"
if git rev-parse --git-dir >/dev/null 2>&1; then
PROJECT_DIR=$(git rev-parse --show-toplevel)
print_success "Found git repository: $PROJECT_DIR"
# Extract org and repo from remote
detect_org_and_repo
echo ""
echo "Detected repository: ${ORG_NAME}/${REPO_NAME}"
echo ""
if ask_yes_no "Set up Catalyst in this repository?"; then
return 0
else
determine_project_location
fi
else
print_warning "Not currently in a git repository"
determine_project_location
fi
}
detect_org_and_repo() {
local git_remote
git_remote=$(git config --get remote.origin.url 2>/dev/null || echo "")
if [[ $git_remote =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then
ORG_NAME="${BASH_REMATCH[1]}"
REPO_NAME="${BASH_REMATCH[2]}"
else
# No GitHub remote, try to parse directory structure
# Assume structure: */github/<org>/<repo>
local abs_path
abs_path=$(cd "$PROJECT_DIR" && pwd)
if [[ $abs_path =~ /github/([^/]+)/([^/]+)/?$ ]]; then
ORG_NAME="${BASH_REMATCH[1]}"
REPO_NAME="${BASH_REMATCH[2]}"
else
# Fallback: ask user
echo ""
print_warning "Could not detect GitHub org/repo from remote or path"
if [[ $NON_INTERACTIVE -eq 1 ]]; then
print_error "Cannot detect GitHub org/repo (no remote, unrecognized path). Run interactively or set a GitHub remote."
exit 1
fi
read -p "Enter GitHub organization name: " ORG_NAME
read -p "Enter repository name: " REPO_NAME
fi
fi
# Determine org root (parent of repo directory)
ORG_ROOT="$(dirname "$PROJECT_DIR")"
# Set projectKey to org name
PROJECT_KEY="$ORG_NAME"