-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcompose-dev
More file actions
executable file
·1657 lines (1509 loc) · 70.4 KB
/
Copy pathcompose-dev
File metadata and controls
executable file
·1657 lines (1509 loc) · 70.4 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/sh
# Copyright © 2026 Gornskew Enterprises
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version. Distributed WITHOUT
# ANY WARRANTY; see <https://www.gnu.org/licenses/agpl-3.0.html>.
# compose-dev — POSIX sh; works with bash, dash, zsh, and macOS /bin/sh
# No bash arrays, no [[ ]], no shopt, no mapfile, no bash-specific constructs.
# =============================================================================
# COMPOSE FILE COLLECTION
#
# POSIX sh has no arrays. COMPOSE_FILES is a newline-delimited string of
# compose file paths, built once at startup. All docker compose invocations
# go through run_compose(), which reconstructs "-f file" pairs from it.
# =============================================================================
cd "${0%/*}"
export DOCKER_CONFIG="${HOME}/.docker"
SCRIPT_DIR="$(pwd)"
# THE SHELL'S SHARE OF A FORK'S DICTIONARY. This script speaks the
# canonical ship register natively -- the ${BASILISK_VOCAB_*} fallbacks
# below ARE canon's words (muster titles, the stowaway designator,
# absence warnings). A FORK never edits them here: its glossary.sexp
# :vocabulary emits generated/vocabulary.env at generation time, this
# sources it, and the shell speaks the fork's words instead. Canon
# ships no glossary and so has no vocabulary.env.
[ -f "${SCRIPT_DIR}/generated/vocabulary.env" ] && \
. "${SCRIPT_DIR}/generated/vocabulary.env"
# Collect compose files into newline-delimited COMPOSE_FILES.
# Order: base -> sorted non-dev overlays -> sorted dev overlays.
_collect_compose_files() {
_base=""
_others=""
_devs=""
[ -f "./docker-compose.yml" ] && _base="./docker-compose.yml"
for _f in ./*.yml; do
[ -f "$_f" ] || continue # manual nullglob: skip if no match
_n="${_f##*/}"
[ "$_n" = "docker-compose.yml" ] && continue
case "$_n" in
*-dev-*) _devs="${_devs:+$_devs
}$_f" ;;
*) _others="${_others:+$_others
}$_f" ;;
esac
done
[ -n "$_others" ] && _others="$(printf '%s\n' $_others | sort)"
[ -n "$_devs" ] && _devs="$(printf '%s\n' $_devs | sort)"
COMPOSE_FILES="${_base}${_others:+
$_others}${_devs:+
$_devs}"
}
# =============================================================================
# NO STANDALONE BOOTSTRAP. CLONE THE REPO.
#
# There used to be a bootstrap_from_image() here: downloaded standalone with
# no clone, it would create a container from the skewed-emacs image and
# docker-cp docker-compose.yml, generate-env.sh and mcp/ back out of the
# repo snapshot baked inside it.
#
# Removed 2026-08-15 (Dave: "no more tricks with extracting yards from
# images"). It only ever existed because the stack machinery lived in the
# skewed-emacs repo and therefore in its image; once the yard is its own
# repo, the honest instruction is the obvious one:
#
# git clone <basilisk> && cd basilisk && ./basilisk up
#
# That also removes a genuinely confusing coupling -- the image is a crew
# member's quarters, not a delivery vehicle for the shipyard -- and one
# whole class of staleness, where a bootstrapped directory kept running
# whatever the image happened to carry.
#
# Anything running from a checkout has docker-compose.yml beside it, so
# there is nothing to detect and nothing to fetch.
# =============================================================================
if [ ! -f "${SCRIPT_DIR}/docker-compose.yml" ]; then
printf '%s\n' "[ERROR] No docker-compose.yml beside this script." >&2
printf '%s\n' " Basilisk runs from a clone of its own repo:" >&2
printf '%s\n' " git clone <basilisk> && cd basilisk && ./basilisk up" >&2
exit 1
fi
_collect_compose_files
# =============================================================================
# run_compose ARGS...
#
# Invoke docker compose with all -f flags prepended. Each arg is individually
# single-quote-escaped before eval so args containing parens, spaces, etc. work.
# =============================================================================
_quote_arg() {
printf "'"
# Escape embedded single quotes as '\'' (close, literal ', reopen).
# NB the quadruple backslash: the shell halves it, and sed needs \\
# in the replacement to emit a literal backslash. The old double-
# backslash version emitted ''' (no backslash), which left an
# unterminated quote whenever an argument contained a ' -- e.g. the
# elisp sent by refresh_emacs_runtime_config ('ok, 'deferred), which
# therefore always failed silently.
printf '%s' "$1" | sed "s/'/'\\\\''/g"
printf "'"
}
run_compose() {
_rc_fargs=""
while IFS= read -r _cf; do
[ -n "$_cf" ] && _rc_fargs="$_rc_fargs -f $(_quote_arg "$_cf")"
done << _FILELIST
$COMPOSE_FILES
_FILELIST
_rc_caller=""
for _a in "$@"; do
_rc_caller="$_rc_caller $(_quote_arg "$_a")"
done
eval "$COMPOSE_CMD $_rc_fargs $_rc_caller"
}
# =============================================================================
# COLOR OUTPUT
# printf %b interprets \033 escapes; works in bash, dash, zsh, macOS sh.
# =============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
log_info() { printf "%b\n" "${BLUE}[INFO]${NC} $1"; }
log_success() { printf "%b\n" "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { printf "%b\n" "${YELLOW}[WARNING]${NC} $1"; }
log_error() { printf "%b\n" "${RED}[ERROR]${NC} $1"; }
# =============================================================================
# PREFLIGHT
# =============================================================================
preflight_checks() {
_pf_ok=true
if ! command -v docker > /dev/null 2>&1; then
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ERROR: docker not found on PATH ║"
echo "╠══════════════════════════════════════════════════════════════════╣"
echo "║ Install Docker Desktop (macOS/Windows) or Docker Engine (Linux)║"
echo "║ https://www.docker.com/products/docker-desktop/ ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
_pf_ok=false
elif ! docker info > /dev/null 2>&1; then
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ERROR: docker is installed but not running / not accessible ║"
echo "╠══════════════════════════════════════════════════════════════════╣"
echo "║ macOS/Windows: start Docker Desktop and wait for green status ║"
echo "║ Linux: sudo systemctl start docker ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
_pf_ok=false
fi
[ "$_pf_ok" = "true" ]
}
# =============================================================================
# DOCKER COMPOSE DETECTION + FILE LIST DISPLAY
# =============================================================================
check_docker_compose() {
if docker compose version > /dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif docker-compose version > /dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
log_error "Neither 'docker compose' nor 'docker-compose' is available"
exit 1
fi
log_info "Using: $COMPOSE_CMD"
log_info "Compose files (application order - later files override earlier):"
while IFS= read -r _full_path; do
[ -n "$_full_path" ] || continue
_fn="${_full_path##*/}"
case "$_fn" in
docker-compose.yml)
log_info " - ${CYAN}${_fn}${NC} ${BLUE}(base)${NC}" ;;
*-dev-*)
log_info " - ${MAGENTA}${_fn}${NC} ${YELLOW}(dev overlay)${NC}" ;;
*)
log_info " - ${GREEN}${_fn}${NC} ${GREEN}(added services)${NC}" ;;
esac
done << _FILELIST
$COMPOSE_FILES
_FILELIST
}
# =============================================================================
# ENVIRONMENT INIT
# =============================================================================
init_env() {
PROJECTS_DIR="${PROJECTS_DIR:-${HOME}/projects}"
export PROJECTS_DIR
# stat flag differs: Linux -c '%g', macOS -f '%g'
if [ -S "/var/run/docker.sock" ]; then
DOCKER_GROUP_ID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null \
|| stat -f '%g' /var/run/docker.sock 2>/dev/null \
|| echo 999)"
export DOCKER_GROUP_ID
log_info "Detected Docker group ID: $DOCKER_GROUP_ID"
else
export DOCKER_GROUP_ID=999
log_warning "Docker socket not found, using default group ID: 999"
fi
if [ ! -d "$PROJECTS_DIR" ]; then
log_info "Creating projects directory: $PROJECTS_DIR"
mkdir -p "$PROJECTS_DIR"
fi
}
ensure_env() {
"${SCRIPT_DIR}/generate-env.sh"
}
# =============================================================================
# HELP
# =============================================================================
show_help() {
cat << EOF
Basilisk — brings up the skewed-emacs stack: Emacs, the Gendl and GDL
engines, Cyclops and autoheal, crewed onto one network and validated.
'Basilisk' names the STACK. 'skewed-emacs' keeps meaning the Emacs
configuration and the image carrying it, which is one container in here.
This script answers to both names: ./basilisk and ./compose-dev are the
same file, so nothing that already says compose-dev breaks. See
BASILISK.md.
USAGE:
$0 <command> [options]
COMMANDS:
up [service...] Start services (default: all services)
down Stop and remove all services
restart [service] Restart service(s)
stop [service] Stop service(s) without removing
logs [service] Show logs for service(s)
status Show status of all services
ps Show running containers
emacs Connect to Emacs via emacsclient
pull Pull missing images (use PULL_ALWAYS=1 to pull latest)
clean Remove containers, networks, and images
init Initialize environment (.env setup via generate-env.sh)
install-shell-functions Write eskew/egskew helpers to shell RC file(s)
config Show merged Docker Compose configuration
SERVICES:
Services are defined in docker-compose.yml and any other .yml files
in this directory (generated from basilisk.sexp, the editable SSoT).
VARIANT SWITCHES (skewed-emacs image; anywhere on the command line):
--lite | --default | --tui | --gui | --full
Set EMACS_IMAGE_VARIANT for this invocation (default: full for the
dev stack). E.g. '$0 up --default' spins up the slim
snapshotting image; '$0 pull --full' pulls the kitchen sink.
EXAMPLES:
$0 up # Start all services
$0 up --pull # Start all services and pull latest images
$0 up --default # Start with the slim default emacs image
$0 up <service> # Start only one service
$0 up --pull <service> # Start one service and pull latest image
$0 logs <service> # Show service logs
PROJECTS_DIR=/path/to/projects $0 up # Use a custom projects directory
$0 emacs # Connect to Emacs interactively
$0 config # Show merged configuration
ENVIRONMENT:
.env is generated by ./generate-env.sh; do not edit it directly.
Additional .yml files in the current directory are automatically included.
Key variables:
PROJECTS_DIR Projects directory to mount (default: ~/projects)
Startup validation (see STARTUP VALIDATION section in this script):
HEALTH_WAIT_SECS per-service validation budget (default 30)
HEALTH_BUDGET_<name> per-service override (name sanitized to
[A-Za-z0-9_], e.g. HEALTH_BUDGET_gendl_ccl=45)
HEALTH_MAX_RESTARTS kill+recreate retries per service (default 2)
HEALTH_POLL_SECS probe cadence (default 2)
NETWORK:
Services communicate via one Docker network per instance -- the
ship himself, named by the yard (generate-env.sh mints into .ship,
surfaced as DOCKER_NETWORK_NAME in .env)
- From host: localhost:<host-port>
- Inter-container: <service-hostname>:<container-port>
EOF
}
# =============================================================================
# SERVICE MANAGEMENT
# =============================================================================
# TEMPLATE SUBSTITUTION -- the yard rewrites the chief's standing
# orders (and any other templated fittings) from the current crew, on
# the way up. templates/ arrives via a stack's ./install; crew.env is
# generated from the articles. A ${BASILISK_POST_<POSTING>}
# placeholder resolves to the name of the first crew member standing
# that posting, and ${BASILISK_POST_<POSTING>_<FREQ>_PORT} to the
# aboard port of that member's named frequency -- so a rule written
# against a POSTING keeps working when the hand standing it is
# renamed or relieved. One hand per posting for now (first declared
# wins); multiples are a known limitation, deliberately unhandled
# until the simple case has sailed.
substitute_templates() {
[ -d "${SCRIPT_DIR}/templates" ] || return 0
# The ledger arrives in layers: the base ship's generated/crew.env
# (tracked yard output) plus any installed overlay ledger
# (generated/<stack>-crew.env, carried in by that stack's
# ./install under its own name -- an install must never overwrite
# a tracked base file, or the clone goes permanently dirty and
# blocks every future pull). Later files win key-wise; one
# overlay ledger is the expected case.
_st_env=$(mktemp) || return 0
_st_found=0
for _st_e in "${SCRIPT_DIR}/generated/crew.env" "${SCRIPT_DIR}/generated/"*-crew.env; do
[ -f "$_st_e" ] || continue
cat "$_st_e" >> "$_st_env"
_st_found=1
done
if [ "$_st_found" = 0 ]; then
log_warning "templates/ present but no crew ledger in generated/; templates NOT rewritten"
rm -f "$_st_env"
return 0
fi
mkdir -p "${SCRIPT_DIR}/generated"
for _st_t in "${SCRIPT_DIR}/templates/"*; do
[ -f "$_st_t" ] || continue
_st_out="${SCRIPT_DIR}/generated/$(basename "$_st_t")"
awk 'NR==FNR {
line=$0; sub(/#.*/,"",line); eq=index(line,"=")
if (eq>1) { k=substr(line,1,eq-1); v=substr(line,eq+1)
gsub(/^[ \t]+|[ \t\r]+$/,"",k); env[k]=v }
next }
{ for (k in env) gsub("\\$\\{" k "\\}", env[k]); print }' \
"$_st_env" "$_st_t" > "$_st_out"
if grep -q '${BASILISK_POST_[A-Z0-9_]*}' "$_st_out"; then
log_warning "$(basename "$_st_out") still names postings nobody stands: $(grep -o '${BASILISK_POST_[A-Z0-9_]*}' "$_st_out" | sort -u | tr '\n' ' ')"
fi
log_info "Rewrote $(basename "$_st_t") -> generated/$(basename "$_st_out") from the crew ledger"
done
rm -f "$_st_env"
}
start_services() {
_ss_services="$*"
init_env
ensure_env
substitute_templates
# Ensure AI TUI auth placeholder files exist on host.
# (Docker bind-mounts create directories for missing files, breaking TUIs.)
for _auth_file in \
"${HOME}/.claude/.credentials.json" \
"${HOME}/.gemini/google_accounts.json" \
"${HOME}/.gemini/oauth_creds.json" \
"${HOME}/.codex/auth.json" \
"${HOME}/.grok/auth.json"
do
if [ ! -f "$_auth_file" ]; then
mkdir -p "$(dirname "$_auth_file")"
touch "$_auth_file"
log_info "Created placeholder: $_auth_file"
fi
done
pull_images
log_info "Starting services: ${_ss_services:-all}"
# shellcheck disable=SC2086 (word-split intentional: service names)
run_compose up -d $_ss_services
# Started != validated: actively probe every healthchecked service
# (kill + recreate hung-on-start containers, e.g. the CCL ASLR
# wedge) BEFORE doing anything that depends on a running daemon.
_ss_rc=0
validate_services || _ss_rc=1
# crew identities: per-container maintenance pass over the
# just-validated fleet, same slot merge_mcp_configs already
# occupies below -- order between the two doesn't matter, this
# one just needs live containers
mint_crew_identities
# MCP merge runs after validation on purpose: a kill+recreate of
# skewed-emacs would discard files copied into the container, and
# a validated skewed-emacs means the daemon is provably ready --
# no blind "waiting for emacs daemon" retry loop needed.
merge_mcp_configs
refresh_emacs_runtime_config
return "$_ss_rc"
}
# =============================================================================
# HOST CLAUDE CODE MCP REGISTRY
#
# There are TWO MCP client registries on a dev box and only one of them was
# ever generated:
#
# mcp/claude_desktop_config.json -- Claude Desktop. Generated output;
# the merge above rewrites it, so it
# follows the checkout automatically.
# ~/.claude.json -- host-side Claude Code. Hand-
# maintained; nothing regenerated it.
#
# When the stack machinery moved from skewed-emacs to basilisk (2026-08-15)
# the Desktop config followed for free and ~/.claude.json did not. It kept
# working only because the move was a COPY -- both mcp/mcp-exec files still
# existed. Deleting the old checkout would have broken every host agent
# session, and not at deletion time: at the next session start, as servers
# that quietly fail to launch. Checking one registry looks like checking
# both, which is exactly why this is now automatic.
#
# Deliberately conservative. It only rewrites entries whose command already
# points at some */mcp/mcp-exec, it writes nothing when nothing is stale
# (the normal case), and it replaces the file atomically after a backup --
# that file also holds live Claude Code state, so it is never rewritten
# wholesale from a stale read.
# =============================================================================
refresh_host_claude_mcp() {
_hcm_file="${HOME}/.claude.json"
[ -f "$_hcm_file" ] || return 0
_hcm_exec="${SCRIPT_DIR}/mcp/mcp-exec"
[ -x "$_hcm_exec" ] || return 0
if ! command -v python3 > /dev/null 2>&1; then
log_warning "python3 not found; cannot check ~/.claude.json MCP paths"
log_warning "If host Claude Code MCP stops resolving, point its"
log_warning "mcpServers commands at ${_hcm_exec}"
return 0
fi
_hcm_out=$(CLAUDE_JSON="$_hcm_file" MCP_EXEC="$_hcm_exec" python3 - <<'PYEOF'
import json, os, shutil, sys, tempfile
path = os.environ["CLAUDE_JSON"]
target = os.environ["MCP_EXEC"]
try:
with open(path) as fh:
raw = fh.read()
data = json.loads(raw)
except Exception as exc: # unreadable or mid-write
print("SKIP %s" % exc)
sys.exit(0)
servers = data.get("mcpServers")
if not isinstance(servers, dict):
sys.exit(0)
changed = []
for name, cfg in servers.items():
if not isinstance(cfg, dict):
continue
cmd = cfg.get("command")
# Only ever retarget something that is already an mcp-exec launcher.
if isinstance(cmd, str) and cmd.endswith("/mcp/mcp-exec") and cmd != target:
cfg["command"] = target
changed.append(name)
if not changed:
sys.exit(0) # normal case: write nothing
shutil.copyfile(path, path + ".bak")
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=".claude.json.")
try:
with os.fdopen(fd, "w") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
shutil.copymode(path, tmp)
os.replace(tmp, path) # atomic
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
print("CHANGED %d %s" % (len(changed), ",".join(sorted(changed))))
PYEOF
) || {
log_warning "Could not update ~/.claude.json MCP paths"
return 0
}
case "$_hcm_out" in
CHANGED*)
log_success "Host Claude Code MCP registry retargeted at ${_hcm_exec}"
log_info " ~/.claude.json updated (backup: ~/.claude.json.bak): ${_hcm_out#CHANGED }"
log_info " Takes effect in the NEXT agent session, not this one."
;;
SKIP*)
log_warning "Could not read ~/.claude.json (${_hcm_out#SKIP }); MCP paths unchecked"
;;
esac
return 0
}
stop_services() {
log_info "Stopping all services"
run_compose down
}
restart_services() {
log_info "Restarting services: ${*:-all}"
run_compose restart "$@"
validate_services
# a plain restart keeps the container's filesystem, so this is a
# no-op for every service today (see the CREW IDENTITY MINTING
# header comment) -- wired in now so the hook point already exists
# once recreate-vs-restart semantics get worked out
mint_crew_identities
}
# =============================================================================
# CREW IDENTITY MINTING (the Basilisk ship-and-crew conceit, framework-level)
#
# Mints a per-container identity -- NAME (random, species-flavored),
# SPECIES, ROLE -- and writes it into /tmp/skewed-crew-identity inside
# each running container, once.
#
# The two axes come from two different places, on purpose. SPECIES is
# the IMAGE TYPE, read off Config.Image and never looked up. ROLE
# comes from the POSTING -- the basilisk.post label, falling back to
# the hostname that basilisk.sexp already assigns. So renaming a
# service in an overlay changes what it stands, not what it is, and
# swapping its image changes what it is, not what it stands.
#
# No schema change, every existing overlay (sally-stack, shelly-stack,
# ...) participates automatically, and any container this script has
# never heard of before still gets a generic crew identity with its
# image as its species.
#
# IDEMPOTENT BY DESIGN: skips any container whose identity file already
# exists. A plain docker restart (same filesystem) therefore keeps
# its name; only a genuinely fresh/recreated container (new writable
# layer, no file) mints one. Whether that is the full desired restart
# semantics is an open question (Dave, 2026-08-14) -- this is the
# conservative default until that gets worked through.
#
# Consumers: eyes-only-metrics.lisp's MY-IDENTITY and (for the Captain)
# EMACS-SAMPLE-ELISP both read this file in preference to minting their
# own name -- see apps/eyes-only source/metrics.lisp.
# =============================================================================
# SPECIES IS THE IMAGE TYPE -- repo:tag, DERIVED and never looked up
# (Dave, 2026-08-17). There is no species table here any more, and no
# species registry anywhere: Basilisk does not adjudicate whether an
# image qualifies as a species, because the image simply IS one.
#
# The REGISTRY/NAMESPACE is stripped. It is the image's home planet --
# its provenance -- and says nothing about what the image IS, so
# gornskew/gendl and someone else's gendl are the same species from
# different worlds. (This replaces an older reading in which the
# genworks/gdl vs gornskew/gendl namespace was the signal behind the
# licensing tier. That distinction is real, but it is provenance, and
# it now rides on the tag: :devo-enterprise-smp-licensed says it.)
#
# The TAG IS PART OF THE SPECIES. gendl:devo-ccl and gendl:devo-sbcl
# are different species, which is precisely what the old glob table
# lost -- its */gendl:* arm dropped the tag and mustered both engineers
# alike. Cute species names, for images we control, are issued as tags.
#
# Matched against the running container's own Config.Image (docker
# inspect), so this needs nothing from basilisk.sexp and works for a
# service this script has never named.
_crew_species_from_image() {
_csi_ref="${1##*/}" # drop registry/namespace; a registry
# port colon rides along with it
_csi_ref="${_csi_ref%%@*}" # a digest pin is not a species
case "$_csi_ref" in
"") echo "unknown" ;;
*:*) echo "$_csi_ref" ;;
*) echo "${_csi_ref}:latest" ;; # implicit tag, made explicit
esac
}
# Role comes from the POSTING, not the species (Dave, 2026-08-16).
#
# Deriving role from the image was only ever a guess made in the absence
# of a declared posting, and the canon makes it provably wrong: a
# Genworks GDL Engineer with Cyclops loaded into it stands in as the
# Pilot -- same species, different role -- so any species->role table
# mislabels it. The hostname now DECLARES the posting, so the role is
# read rather than inferred.
#
# There is no longer a post->species table either (Dave, 2026-08-17).
# It ran backwards: what usually stands a post is a reasonable thing to
# know, but it is not how species is resolved. Species comes off the
# image and nowhere else, so a post that cannot be recognized costs a
# stylish name and nothing more.
#
# Trailing -N is tolerated so a ship can carry more than one of a post
# (jr-eng-cyborg-2 is still an engineer).
#
# transporter-chief is the CURRENT name of the ingress posting (a
# reverse proxy receives; it never goes anywhere, so "pilot" described
# something the software does not do). The pilot arm stays for
# containers created before the rename; both map to the same ROLE
# string, "pilot", because the role vocabulary reaches the eyes-only
# per-skin rank titles and renaming IT is a separate, still-open
# decision (Dave) -- do not rename the role here as a tidy-up.
_crew_role_for_posting() {
case "$1" in
captain|captain-[0-9]*) echo "captain" ;;
transporter-chief|transporter-chief-[0-9]*) echo "pilot" ;;
pilot|pilot-[0-9]*) echo "pilot" ;;
medic|medic-[0-9]*) echo "doctor" ;;
communications-officer|communications-officer-[0-9]*) echo "comm" ;;
comms|comms-[0-9]*) echo "comm" ;;
"${BASILISK_VOCAB_STOWAWAY_DESIGNATOR:-stowaway}"-*) echo "stowaway" ;;
ships-engineer|ships-engineer-[0-9]*) echo "engineer" ;;
guild-engineer|guild-engineer-[0-9]*) echo "engineer" ;;
jr-eng-*|sr-eng-*|guild-*) echo "engineer" ;;
*) echo "crew" ;;
esac
}
# A basilisk.post label may carry a LIST (comma-joined): one crew
# member standing several posts. No posting is primary and ordering
# carries no meaning, but ROLE= is single-valued legacy -- so take the
# first RECOGNIZED role scanning the list, without reading position as
# primacy.
_crew_role_for_postings() {
_crp_role="crew"
_crp_old_ifs=$IFS; IFS=','
for _crp_p in $1; do
_crp_r="$(_crew_role_for_posting "$_crp_p")"
if [ "$_crp_r" != "crew" ]; then
_crp_role="$_crp_r"
break
fi
done
IFS=$_crp_old_ifs
echo "$_crp_role"
}
# MANNED/DRONE IS GONE FROM HERE, deliberately (Dave, 2026-08-17).
#
# It asked exactly one question -- is there a compiler aboard? -- and
# that is a CAPABILITY OF THE IMAGE, not a trait of a species, so it
# cannot be derived from a species table without guessing. The image
# will answer for itself once our Dockerfiles carry OCI labels; they
# carry none today, so there is nothing to read yet and the field is
# not written at all rather than written as a guess.
#
# Nothing is lost in the meantime: MANNED= was computed, written and
# then read by nobody (verified 2026-08-17 -- the only sites that
# mentioned it were the four that produced it). The lore distinction
# it encodes is real and load-bearing on pricing (a drone can be free
# or commercial, and the difference is priced), which is the argument
# for wiring it properly rather than for keeping a table that only
# ever approximated it.
#
# Do not reintroduce a species->manned case statement here.
# Name-pool syllables per species -- kept in sync BY HAND with
# eyes-only-metrics.lisp's *CREW-NAME-POOLS* (that Lisp copy is now the
# fallback path only; this shell copy is authoritative whenever
# compose-dev is what started the stack).
#
# THIS IS A STYLE TABLE, NOT A SPECIES REGISTRY. It grants no
# membership and gates nothing: a species absent from it is a perfectly
# good species that draws from the unknown pool, which costs a stylish
# name and nothing else. Defaults are fine; registries are not.
#
# Keyed on repo:tag and MATCHED MOST-SPECIFIC-FIRST, which is how the
# tag earns its keep: gendl:devo-ccl and gendl:devo-sbcl are different
# species and now sound like it, instead of both mustering out of the
# one gendl pool as they did until 2026-08-17. Order matters in both
# case statements -- *non-smp* must precede *smp*, which would
# otherwise swallow it.
#
# COMMS (eyes-only) is here before its container is: Eyes Only has no
# image yet, so nothing mints one today, but it lands the moment the
# board ships as a binary with a :post :comms. Until 2026-08-17 it
# would have fallen through to the unknown pool and sounded like no
# one. The Comms character -- lilting and nasal, resolving on a hum,
# so a name trails off rather than stopping -- is deliberately the one
# corner of the sonic space the other
# pools leave empty: captain consonant-heavy, gendl liquid, gdl
# English-gentry, cyclops clipped and sibilant, autoheal vocalic.
_crew_onsets_for() {
case "$1" in
skewed-emacs:*) echo 'Bl Gr Thr Kro Zar Vex Mog Drel' ;;
cyclops:*) echo 'Bl Zh Vek Skr Yor Tsa Kli Dro' ;;
gendl:*sbcl*) echo 'Vas Ael Ion Cyr Tha Mor Zel Ryn' ;;
gendl:*) echo 'Ka Lis Par Quo Sem Tel Nym Ori' ;;
gdl:*non-smp*) echo 'Ad Bex Cor Dal Enn Fir Gal Hux' ;;
gdl:*) echo 'Ost Ven Kal Rhe Sig Tor Wex Yar' ;;
autoheal:*) echo 'Om Sal Vel Lum Mir Ana Eir Thea' ;;
eyes-only:*) echo 'Nil Myr Len Vael Sion Nyel Lir Nao' ;;
*) echo 'X Yz Qm' ;;
esac
}
_crew_finals_for() {
case "$1" in
skewed-emacs:*) echo 'org arn und ath esh ork ilk uz' ;;
cyclops:*) echo 'ej ix osk arr unn eth yx oa' ;;
gendl:*sbcl*) echo 'ien uur eos aal ymn ore ith yss' ;;
gendl:*) echo 'da per lon ta mir vex is und' ;;
gdl:*non-smp*) echo 'mand ton dry well berg worth field ston' ;;
gdl:*) echo 'holm gate forge crest march stead vale bury' ;;
autoheal:*) echo 'a une is oon ara elle ios em' ;;
eyes-only:*) echo 'am een ion um ain oem un yn' ;;
*) echo 'ar il ot' ;;
esac
}
# Random 1-based pick from a space-separated word list ($1). No bash
# arrays/$RANDOM (dash has neither) -- /dev/urandom via od, same
# portability posture as the rest of this script.
_crew_pick() {
set -- $1
_cp_n=$#
_cp_r=$(od -An -N2 -tu2 /dev/urandom | tr -d ' ')
_cp_i=$(( (_cp_r % _cp_n) + 1 ))
eval "echo \${$_cp_i}"
}
# One species-flavored name, unique against every name minted so far
# THIS RUN (_crew_minted_names). 20 collisions -> numeral-disambiguated
# (mirrors eyes-only-metrics.lisp's MINT-CREW-NAME).
_crew_unique_name() {
_cun_species="$1"
_cun_tries=0
while :; do
_cun_name="$(_crew_pick "$(_crew_onsets_for "$_cun_species")")$(_crew_pick "$(_crew_finals_for "$_cun_species")")"
case " ${_crew_minted_names} " in
*" ${_cun_name} "*)
_cun_tries=$((_cun_tries + 1))
[ $_cun_tries -ge 20 ] && {
_cun_name="${_cun_name}-$(od -An -N1 -tu1 /dev/urandom | tr -d ' ')"
break
}
continue
;;
*) break ;;
esac
done
# set a result var, NOT echo+$(...) -- a command-substitution call
# would run this function in a SUBSHELL, silently discarding its
# write to _crew_minted_names (caught empirically 2026-08-14: two
# calls minted the same name back to back). Callers read
# $_crew_unique_name_result after calling this directly.
_crew_minted_names="${_crew_minted_names:+$_crew_minted_names }${_cun_name}"
_crew_unique_name_result="$_cun_name"
}
mint_crew_identities() {
_ci_ids="$(run_compose ps -q 2>/dev/null)"
[ -n "$_ci_ids" ] || return 0
_crew_minted_names=""
for _ci_id in $_ci_ids; do
_ci_name="$(docker inspect --format "{{.Name}}" "$_ci_id" 2>/dev/null)" || continue
_ci_name="${_ci_name#/}"
if docker exec "$_ci_name" test -f /tmp/skewed-crew-identity 2>/dev/null; then
continue # already identified -- filesystem survived, keep the name
fi
_ci_image="$(docker inspect --format \
"{{.Config.Image}}" \
"$_ci_id" 2>/dev/null)"
# Posting is the container's hostname, which the generator set
# from the SSoT :name. Fall back to the container name with any
# BASILISK_PREFIX stripped, for containers created before this.
_ci_posting="$(docker inspect --format "{{.Config.Hostname}}" \
"$_ci_id" 2>/dev/null)"
[ -n "$_ci_posting" ] || _ci_posting="${_ci_name#${BASILISK_PREFIX:-}}"
# Species is the image type, full stop -- no label to consult,
# no post default to fall back to, nothing to resolve between
# (Dave, 2026-08-17). The old three-step chain also had an
# ordering bug that dies with it: its post-default arm ran
# against the HOSTNAME-derived posting, because the
# basilisk.post label override below had not happened yet.
_ci_species="$(_crew_species_from_image "$_ci_image")"
# Post: the declared label wins, the hostname is the
# fallback for containers created before labels existed.
_ci_post="$(docker inspect \
--format '{{index .Config.Labels "basilisk.post"}}' \
"$_ci_id" 2>/dev/null)"
[ -n "$_ci_post" ] && _ci_posting="$_ci_post"
_ci_role="$(_crew_role_for_postings "$_ci_posting")"
_crew_unique_name "$_ci_species"
_ci_mint_name="$_crew_unique_name_result"
# NAME= stays FIRST: every reader is key-based except
# skewed-emacs's dashboard, which reads line 1 and requires the
# NAME= prefix. MANNED= used to be appended last and is no
# longer written at all -- see the note where its table was;
# every reader scans for its own keys, so dropping it is
# backward-compatible the same way adding it was.
if docker exec "$_ci_name" sh -c \
"printf 'NAME=%s\nSPECIES=%s\nROLE=%s\n' '$_ci_mint_name' '$_ci_species' '$_ci_role' > /tmp/skewed-crew-identity" \
2>/dev/null
then
log_success " ${_ci_name}: crew identity minted -- ${_ci_mint_name} (${_ci_role}, ${_ci_species})"
else
log_warning " ${_ci_name}: could not write crew identity (no shell/writable rootfs?)"
fi
done
}
# =============================================================================
# MCP CONFIG MERGE
#
# Writes merged MCP config files inside the skewed-emacs container and
# updates /home/emacs-user/.codex/config.toml and ~/.grok/config.toml.
# Runs AFTER validate_services: overlay copies into a container that
# validation might still kill+recreate would be lost, and a validated
# skewed-emacs (its lisply-ping healthcheck is served by the emacs
# daemon itself) proves the daemon is ready -- the merge runs exactly
# once, event-driven, replacing the old 5x2s blind retry loop.
# =============================================================================
merge_mcp_configs() {
log_info "Preparing MCP configurations (JSON + TOML)..."
case " ${VALIDATED_OK:-} " in
*" captain "*) ;;
*)
# The Captain was not validated this run (single-service up,
# failed validation, or already running from before): probe the
# daemon once directly instead of looping blind.
if ! run_compose exec -T captain emacsclient --eval t \
> /dev/null 2>&1
then
log_warning "Captain daemon not available; skipping MCP config merge"
return 1
fi
;;
esac
# Lite-variant images do not ship ~/.codex and ~/.grok, so docker
# creates those file-mount parent dirs ROOT-OWNED at container
# create time (a recreate reintroduces them; host-side chown cannot
# reach container-fs dirs). The merge writes config.toml into both,
# so repair ownership first; harmless no-op on full images.
run_compose exec -T -u root captain sh -c \
"chown -R emacs-user /home/emacs-user/.codex /home/emacs-user/.grok" \
> /dev/null 2>&1 || true
# Step 1: Stage the YARD's mcp/ into the Captain.
#
# mcp/ left the skewed-emacs repo when it was spun off to basilisk,
# so the image no longer carries /home/emacs-user/skewed-emacs/mcp
# and neither the base configs nor merge-mcp-configs.el are aboard.
# This used to copy overlays into that now-absent directory with
# `|| true`, so every copy failed silently and the merge then died
# on a missing load-file -- surfacing only as "daemon answered but
# merge errored" (found on a real deploy, 2026-08-16). The yard owns
# these files now, so the yard hands them over at up-time.
#
# Same failure shape as the mcp/entrypoint.sh incident: a split moved
# files by concept without checking every consumer.
_mcp_stage="/tmp/basilisk-mcp"
run_compose exec -T captain mkdir -p "$_mcp_stage" > /dev/null 2>&1 || {
log_warning "could not create $_mcp_stage in the Captain; skipping MCP merge"
return 1
}
for _mcpfile in \
"${SCRIPT_DIR}/mcp/merge-mcp-configs.el" \
"${SCRIPT_DIR}/mcp/mcp-container.json" \
"${SCRIPT_DIR}/mcp/mcp-windows.json" \
"${SCRIPT_DIR}/mcp/mcp.toml" \
"${SCRIPT_DIR}/mcp/"*-mcp-windows.json \
"${SCRIPT_DIR}/mcp/"*-mcp-container.json \
"${SCRIPT_DIR}/mcp/"*-mcp.toml
do
[ -f "$_mcpfile" ] || continue
log_info "Staging: $(basename "$_mcpfile")"
run_compose cp "$_mcpfile" "captain:${_mcp_stage}/" 2>/dev/null \
|| log_warning " could not stage $(basename "$_mcpfile")"
done
# Step 1b: Copy overlay services-generated Emacs files, PLUS
# services-discovery.el -- the discovery/merge logic and the
# generated files are a matched pair (sparse overlay entries rely
# on key-wise merging in discovery). Syncing one without the
# other skews them when the running image predates a discovery
# change (bit us 2026-08-09: gendl-ccl vanished from the dashboard
# Swank list on a stale image + fresh sparse overlay).
# Sources differ on purpose. services-generated.el files are YARD
# OUTPUT and live in generated/; services-discovery.el is Emacs
# CONFIGURATION and stays in the Captain's dot-files tree. That split
# is the whole point of the basilisk move -- see MIGRATION.md.
#
# The glob is *services-generated.el, NOT *-services-generated.el, and
# the missing dash is load-bearing. It has to match the unprefixed
# BASE file as well as the <host>-stack- prefixed overlays. Until the
# rip, the base file reached the container baked into the skewed-emacs
# image, so only overlays needed copying; now that the yard has left
# that repo the image no longer carries it, and a dashed glob would
# silently ship a container that knows about cyclops and the GDL units
# but not about skewed-emacs, gendl-sbcl or autoheal.
for _overlay in "${SCRIPT_DIR}/generated/"*services-generated.el \
"${SCRIPT_DIR}/dot-files/emacs.d/etc/services-discovery.el"; do
[ -f "$_overlay" ] || continue
log_info "Copying overlay: $(basename "$_overlay")"
run_compose cp "$_overlay" \
captain:/home/emacs-user/skewed-emacs/dot-files/emacs.d/etc/ 2>/dev/null || true
done
# Step 2: Merge inside container -- one shot; the daemon is ready.
if run_compose exec -T captain emacsclient --eval \
"(progn (setenv \"SKEWED_CLONE_PATH\" \"${SCRIPT_DIR}\")
(load-file \"${_mcp_stage}/merge-mcp-configs.el\")
(skewed-merge-all-mcp-configs \"${_mcp_stage}\"))" \
> /dev/null 2>&1
then
log_success "MCP configurations merged for container CLI, Windows, Codex, and Grok"
# Copy the platform-appropriate Claude Desktop config back to the
# host clone (regression fix: the pre-POSIX compose-dev did this).
# WSL hosts get the wsl-wrapped config; Linux/macOS get the native one.
if grep -qi microsoft /proc/version 2>/dev/null; then
_cdc_src="/tmp/merged-mcp-windows.json"
else
_cdc_src="/tmp/merged-mcp-host.json"
fi
# Claude CODE's registry is a different file from Claude
# Desktop's, and cannot be copied over: ~/.claude.json carries
# machineID, userID, seen-notification state and migration flags
# alongside mcpServers. So the yard also drops the host-form
# config here, and mcp/install-claude-code-config splices just
# its mcpServers key into ~/.claude.json. Always the host form,
# never the windows one -- Claude Code runs inside WSL, where
# the wsl.exe wrapper the Desktop config needs would be wrong.
run_compose cp "captain:/tmp/merged-mcp-host.json" \
"${SCRIPT_DIR}/mcp/claude-code-mcp.json" 2>/dev/null \
|| log_warning "could not stage mcp/claude-code-mcp.json"
if run_compose cp "captain:${_cdc_src}" \
"${SCRIPT_DIR}/mcp/claude_desktop_config.json" 2>/dev/null; then
log_success "Claude Desktop config ready: mcp/claude_desktop_config.json (see docs/CLAUDE_DESKTOP.md)"
else
log_warning "Could not copy Claude Desktop config from container"
log_warning "(an older skewed-emacs image may not generate ${_cdc_src};"
log_warning " try './compose-dev up --pull' to fetch the latest image)"
fi
refresh_host_claude_mcp
else
log_warning "MCP configuration merge failed (daemon answered but merge errored)"
log_warning "Retry with: ./compose-dev up"
return 1
fi
}
# =============================================================================
# STARTUP VALIDATION ("container started" != "service validated")
#
# CCL occasionally loses its ASLR dice roll at startup ("exception 11" /
# "bad frame in rt_sigreturn", upstream Clozure/ccl#85): the process
# does not exit -- it hangs in the CCL kernel debugger -- so
# `restart: unless-stopped` never fires and the container sits there
# "running" while never becoming healthy. The generated healthchecks
# have long intervals (gendl-ccl: 72s x 3 retries), so docker/autoheal