-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprivate_dot_bash_env
More file actions
767 lines (711 loc) · 38.6 KB
/
Copy pathprivate_dot_bash_env
File metadata and controls
767 lines (711 loc) · 38.6 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
# Bash environment configuration
# This file is managed by chezmoi
# Sourced by .bashrc for shell initialization
# === SSH Agent Setup (using pixi openssh) ===
#
# The socket path is fixed for two reasons, not one: every terminal on a host
# shares a single agent through it, and devlaunch's devcontainer bind-mounts the
# host's ~/.ssh/agent.sock onto the same path inside a workspace, which is the
# only way a container gets an agent at all. So the path stays put -- but
# nothing below may *claim* it before something answers on the other end.
#
# That claim was the bug (issue #31): the export came first and the agent second,
# with the failure silenced. `ssh-agent -a` will not create its socket's parent
# directory, so in a container with no ~/.ssh it failed and said nothing, leaving
# every shell naming an agent that was never started. ssh then reports a broken
# agent rather than an absent one, which is the more expensive thing to debug.
# 0 when an agent answers on $1. ssh-add's exit 1 is "alive, no identities
# loaded" -- alive is the question here, so it counts; only 2 (cannot connect)
# means nothing is there. The old code read 1 as death and killed a live agent.
#
# Bounded, because "cannot connect" is not the only way for an agent to be gone.
# A socket whose listener still exists but has stopped answering -- an ssh-agent
# wedged or stopped, a container's socket left bound after the container went
# away -- accepts the connection and then never replies, so connect() succeeds
# and the read blocks with no timeout of its own. Unbounded, that hangs every
# shell which sources this file: the terminal never reaches a prompt, and Ctrl-C
# does not merely unblock it, it abandons the rest of .bashrc -- so the shell
# comes up without bash completion, which reads as a second, unrelated bug.
#
# timeout's 124 is therefore a third kind of dead, and the one a plain exit
# status cannot see. It counts as "no agent here", which sends the caller down
# the same path a refused connection does: replace the socket and start again.
_ssh_agent_probe() {
local status
SSH_AUTH_SOCK="$1" timeout 2 ssh-add -l >/dev/null 2>&1
status=$?
[ "$status" -eq 0 ] || [ "$status" -eq 1 ]
}
_ssh_agent_setup() {
local sock="$HOME/.ssh/agent.sock"
local log="${XDG_STATE_HOME:-$HOME/.local/state}/ssh-agent-bootstrap.log"
local key
# An agent forwarded into this shell (ssh -A, a desktop or systemd agent) is
# already the right answer; the old unconditional export threw those away.
# It has to actually answer, though -- an inherited stale socket is no better
# than an invented one, so a dead value falls through to the local agent.
if [ -n "${SSH_AUTH_SOCK:-}" ] && [ "$SSH_AUTH_SOCK" != "$sock" ] &&
_ssh_agent_probe "$SSH_AUTH_SOCK"; then
return 0
fi
if ! _ssh_agent_probe "$sock"; then
# The log is truncated per attempt, so it holds the last failure rather
# than growing a line per shell. A home that cannot hold it is not worth
# failing over: a redirect to an unwritable path would take the agent
# start down with it.
mkdir -p "${log%/*}" 2>/dev/null
# Braces, so the group's redirect catches the *inner* redirect's own
# failure message -- `: >"$log" 2>/dev/null` prints before 2> applies.
{ : >"$log"; } 2>/dev/null || log=/dev/null
mkdir -p "$HOME/.ssh" 2>>"$log" && chmod 700 "$HOME/.ssh" 2>>"$log"
# Stale socket file, or a bind mount, which refuses to be removed (busy)
# and does not need to be -- the probe above already found it dead.
rm -f "$sock" 2>>"$log"
ssh-agent -a "$sock" >/dev/null 2>>"$log"
fi
if ! _ssh_agent_probe "$sock"; then
# Unset beats lying. One line for a human, and the reason on disk, which
# is where a non-interactive shell's owner can still go and read it.
unset SSH_AUTH_SOCK
case $- in
*i*) printf 'ssh-agent: no agent at %s (see %s)\n' "$sock" "$log" >&2 ;;
esac
return 1
fi
export SSH_AUTH_SOCK="$sock"
# Load a key only when the agent holds none, and never interactively: a
# passphrase-protected key would otherwise prompt in every new shell, and
# ssh-add reaching for a tty is how a one-command shell hangs forever.
#
# SSH_ASKPASS_REQUIRE=never was the wrong spelling of that intent. It means
# "never use an askpass helper", which leaves the *tty* as the way to ask --
# so an encrypted key still prompts wherever there is one, which is every
# terminal. `force` plus a helper that fails is the combination that cannot
# ask at all: ssh-add is required to go through the helper, the helper
# refuses, and it gives up at once. DISPLAY is cleared for the same reason,
# so a helper inherited from the environment cannot put a dialog on screen.
if ! timeout 2 ssh-add -l >/dev/null 2>&1; then
for key in id_ed25519 id_rsa; do
[ -f "$HOME/.ssh/$key" ] || continue
DISPLAY= SSH_ASKPASS=/bin/false SSH_ASKPASS_REQUIRE=force \
timeout 5 ssh-add "$HOME/.ssh/$key" </dev/null \
2>>"${log:-/dev/null}" && break
done
fi
}
_ssh_agent_setup
# === Pixi Setup ===
# PIXI_HOME is pixi's own knob for where global envs and the manifest live
# (default ~/.pixi). Everything below resolves through it rather than hardcoding
# ~/.pixi, so the container override further down redirects the whole block.
export PIXI_HOME="${PIXI_HOME:-$HOME/.pixi}"
# Kinisi dev containers own ~/.pixi: their entrypoint symlinks the manifest to a
# file inside the kinisi_ros checkout, so installing into it would write through
# to the repo and dirty a shared work tree. Take a private root instead.
#
# Two tests, because the first one alone got this wrong. KINISI_INSTANCE is set
# (possibly empty) only by the kinisi compose files, so `+x` set-ness — not
# `:-x` non-emptiness — is the test: the main clone has no instance suffix. But
# the compose files are not the only thing that claims ~/.pixi. kinisi_ros's own
# devcontainer makes the identical symlink from .devcontainer/on_create.sh, and a
# devpod/devlaunch launch of that repo sets no KINISI_INSTANCE — so this fell
# through to ~/.pixi and every `pixi global install` in the workspace appended
# through the link into the checkout, leaving `git status` permanently dirty.
#
# So the second test asks about the claim itself rather than about who made it:
# a manifest that is a symlink was put there by something that is not us. That
# covers both containers, and any future one, without naming either.
#
# The path is deliberately under ~/.local/share, which every kinisi container
# bind-mounts from the host, and HOME is /home/kinisi in all of them — so one
# install is shared by every clone's container and survives recreation. The
# arch suffix keeps x64 envs away from the arm64 thor/nanopi containers that
# would otherwise resolve the identical path. It is never used on the host, so
# the host prefix and the container prefix can never collide.
#
# Skipped under ags, which runs with HOME reassigned to its own cache: this path
# is HOME-relative, so it would resolve to a root nested inside that cache
# ($AGS_HOME/.local/share/pixi-container-*) which nothing ever installs into,
# and drop the ags pixi bin dir from PATH. ags brings its own complete tree and
# is meant to be independent of the surrounding machine, container or not.
if { [ -n "${KINISI_INSTANCE+x}" ] ||
[ -L "$PIXI_HOME/manifests/pixi-global.toml" ]; } && [ -z "${AGS_SHELL:-}" ]; then
export PIXI_HOME="$HOME/.local/share/pixi-container-$(uname -m)"
fi
# Prepend to PATH only if it is not already there. This file is sourced twice in
# a kinisi container -- once early for the environment, once late for the
# interactive half (see the guard below) -- and a plain prepend would put every
# one of these directories on PATH twice.
_path_prepend() {
case ":$PATH:" in
*":$1:"*) ;;
*) PATH="$1:$PATH" ;;
esac
}
# === Local bin ===
# Before the pixi root, so that root ends up ahead of it: these are prepends, so
# the LAST one named is the first one searched.
#
# The order only started mattering on 2026-08-25, when Claude Code's native
# installer put a `claude` symlink in ~/.local/bin. This repo already exposes
# `claude` from the claude-shim pixi env, so the two collide, and until then
# nothing did -- `claude` resolved to the shim because it was the only one. Pixi
# wins here to keep it that way. It is the sole overlap between the two
# directories; everything else in ~/.local/bin is reached exactly as before.
_path_prepend "$HOME/.local/bin"
export PATH
_path_prepend "$PIXI_HOME/bin"
export PATH
if command -v pixi &> /dev/null; then
eval "$(pixi completion --shell bash)"
fi
# === npm global bin (via pixi nodejs) ===
_path_prepend "$PIXI_HOME/envs/nodejs/bin"
export PATH
# Collapse whatever ran before this point. Everything above prepends through
# _path_prepend, so the duplicates are not from here -- they arrive from the
# files that bracket this one, each of which prepends unconditionally and none
# of which knows about the others: Ubuntu's .profile re-adds ~/.local/bin after
# it has already sourced .bashrc, kinisi's host-side env script re-adds both, and
# an older install.sh left a line naming a variable that is set nowhere.
#
# That last one is why this drops empty elements as well as duplicates. An empty
# PATH component means the current directory, so `PATH=":$PATH"` silently puts
# cwd first and any directory you cd into can shadow a command. Dropping it is
# the point; deduplication is the tidy-up.
#
# Keeps first occurrence, so precedence is unchanged -- this only ever removes
# later copies of a directory already searched earlier.
_path_dedupe() {
local entry seen="" out=""
while IFS= read -r entry; do
[ -n "$entry" ] || continue
case ":$seen:" in
*":$entry:"*) continue ;;
esac
seen="${seen:+$seen:}$entry"
out="${out:+$out:}$entry"
done <<< "${PATH//:/$'\n'}"
PATH="$out"
}
# Move a directory to the front of PATH, if it is on it at all. Precedence set
# earlier in this file does not survive: blocks sourced *after* it -- the
# generated KINISI_ENVIRONMENT block, Ubuntu's .profile tail -- prepend these
# same directories again and decide the final order themselves. So the ordering
# that matters is re-asserted from the tail hook, once everything has run.
_path_promote() {
case ":$PATH:" in
*":$1:"*) ;;
*) return ;;
esac
PATH="$1:$(echo "$PATH" | sed -e "s|^$1:||" -e "s|:$1:|:|g" -e "s|:$1$||")"
}
_path_dedupe
export PATH
# === Editor ===
# Resolved, not assumed. nvim is gated on the .editor capability flag (personal
# and container); shared machines get vim from the toolbox instead, and
# hardcoding nvim there left EDITOR naming a binary that was never installed — so
# `git commit` with no -m, `fc`, and anything else shelling out to $EDITOR failed.
# Checked at shell init rather than templated because this file is not a template,
# and because the honest condition is "is nvim on PATH", not "which profile is this".
if command -v nvim >/dev/null 2>&1; then
export EDITOR="nvim"
export VISUAL="nvim"
else
export EDITOR="vim"
export VISUAL="vim"
fi
# dl reads NVIM_SPLIT=1 to open $VISUAL beside the agents it launches in Herdr.
# Left unset: a workspace gets opened to run something at least as often as to
# edit, and an editor that starts itself is a process to quit before the pane is
# usable — the same trade that took `nvim .` out of the zellij work tab. Export
# it to get the split back.
# === kinisi_ros devcontainer image freshness ===
# .devcontainer/image_pull.sh reads this from the environment initializeCommand
# inherits, and dl and the devcontainer CLI both pass this shell's. `daily` is
# the same tag check `if-newer` does, rationed to once a day per platform, which
# is what this machine wants: opens come in bursts, and the gcloud round trip on
# every one of them was paid to learn nothing new the other nineteen times.
# A pull reaches the next container create, not the launch that ran it, so the
# adopt is `dl <ws> recreate`. The sim image needs nothing here: it is pinned
# per-commit, and the default KINISI_SIM_PULL=missing already fetches a new pin.
export KINISI_IMAGE_PULL=daily
# === Claude config location ===
# Claude Code keeps its state in two places: the config *directory* (~/.claude —
# settings.json, projects/, sessions/, and the shared .credentials.json) and a
# single large .claude.json holding the logged-in account plus per-project
# history. With CLAUDE_CONFIG_DIR unset, that JSON lands at ~/.claude.json,
# *beside* the directory rather than inside it. Setting the variable moves it to
# $CLAUDE_CONFIG_DIR/.claude.json and moves nothing else.
#
# That one file's position is the whole problem. devlaunch bind-mounts ~/.claude
# into every container, so the directory — credentials included — is genuinely
# shared, but ~/.claude.json is outside the mount and is not. A container
# therefore reads a different .claude.json from the host's while sharing the
# host's token, and the two drift: this machine ran for months with the host file
# on one account and the in-container file still asserting a stale one, against a
# single set of credentials that only ever matched the host. Every symptom of
# that reads as an auth bug rather than as two files.
#
# Pointing at ~/.claude puts the JSON inside the mount, so host and containers
# converge on one file. The cost is that they now all write it: it is rewritten
# whole, so two agents ending a session at once can drop each other's project
# history. That was already true between containers; this makes the host a
# participant, and is the cheaper half of the trade — losing a project's
# scrollback costs nothing next to running as the wrong account.
#
# `:-` so a container that sets this itself keeps its own value.
export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
# Locate a pixi env by name, echoing its prefix. Searches PIXI_HOME first, then
# the stock ~/.pixi. The two differ only inside a kinisi container, where ~/.pixi
# is the image's own root: a tool present there but absent from the personal root
# still has its shell integration sourced, instead of silently not loading
# because `command -v` finds the binary while the share/ path is looked for under
# the wrong prefix. Prints nothing and returns 1 when the env exists in neither.
_pixi_env_prefix() {
local env_name="$1" root
for root in "$PIXI_HOME" "$HOME/.pixi"; do
if [ -d "$root/envs/$env_name" ]; then
printf '%s\n' "$root/envs/$env_name"
return 0
fi
done
return 1
}
# === Local overrides (machine-specific, NOT tracked by chezmoi) ===
# Put per-machine env here; this file is never committed to the dotfiles repo.
if [ -f "$HOME/.bash_env.local" ]; then
source "$HOME/.bash_env.local"
fi
# === Everything above is environment; everything below is interactive ===
#
# A kinisi container's .bashrc sources this file TWICE, and the split is what
# makes that work:
#
# early, with _BASH_ENV_ENV_ONLY=1, from the section that image reserves for
# "BOTH interactive and non-interactive login shells ... so LLM agents can
# execute commands with full environment loaded". That is the only source a
# non-interactive shell ever reaches -- the ROS .bashrc returns a few lines
# later -- and it is what `aid` needs: `dl <workspace> -- claude` runs a
# *command*, so claude inherits a non-interactive shell. Without it PIXI_HOME
# stayed ~/.pixi, the personal pixi root was never on PATH, and the
# `claude-statusline` the bind-mounted settings.json names could not be found.
#
# late, unflagged, from the end of the file, which is where the interactive
# half has to run. Between the two hooks sits the image's copy of Debian's
# prompt block, which picks a PLAIN PS1 whenever TERM is not xterm-color or
# *-256color -- and kitty reports xterm-kitty. Everything below therefore has
# to land after it, or the image overwrites the prompt this file just set. That
# regressed exactly once, by running the whole file early; hence the flag.
#
# So: below this line is shell *interaction* -- the prompt, fzf keybindings
# (`bind` warns outright in a non-interactive shell), zoxide and broot init,
# forgit, the zellij session handling. None of it means anything to a shell
# running one command, and some of it is noisy there.
#
# The environment half above is written to survive running twice: PATH goes
# through _path_prepend, and the rest is plain idempotent exports.
# The final _path_dedupe + _path_promote that modify_private_dot_bashrc appends
# (README, "PATH hygiene") only ever runs for a shell that reaches the end of
# .bashrc -- so a one-command login shell got the personal PATH without the
# precedence that goes with it, and `claude` resolved to ~/.local/bin's native
# installer symlink there while resolving to the pixi shim in a terminal. Same
# file, two answers. Settle it here, before either kind of shell can return.
_path_dedupe
_path_promote "$PIXI_HOME/bin"
export PATH
if [ -n "${_BASH_ENV_ENV_ONLY:-}" ]; then
return
fi
case $- in
*i*) ;;
*) return;;
esac
# === Prompt ===
# The image's prompt block only colors PS1 when TERM is xterm-color or
# *-256color. Kitty reports xterm-kitty, so its colored branch never fires. This
# runs after that block -- see the two-hook note above -- so it decides on real
# color support instead of a TERM pattern.
if [ -x /usr/bin/tput ] && tput setaf 1 >/dev/null 2>&1; then
PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
fi
# === Daily usage digest ===
# The first interactive shell of the day prints yesterday's usage numbers and
# the running usage-review experiments (the skill's references/methodology.md).
# It is the daily rhythm of that review: a script, no agent, read in passing.
# Host only, because the telemetry it reads lives in the host's ~/.claude; and
# capped at 5 s so a slow day never holds up a prompt. `usage-digest` shows it
# again on demand.
usage-digest() {
timeout 5 python3 "$HOME/.claude/skills/usage-review/scripts/usage_stats.py" --digest "$@"
}
# `usage-db` opens DuckDB with every usage log loaded as a table (the skill's
# sql/sources.sql): claude_events, herdr_events, dl_events, turns, prompts. It
# refreshes the transcript cache first, so today's turns are there too.
usage-db() {
local d="$HOME/.claude/skills/usage-review"
python3 "$d/scripts/usage_stats.py" --refresh-cache && duckdb -init "$d/sql/sources.sql" "$@"
}
if [ ! -f /.dockerenv ] && [ -f "$HOME/.claude/skills/usage-review/scripts/usage_stats.py" ]; then
_ud_stamp="${XDG_STATE_HOME:-$HOME/.local/state}/usage-review/digest-$(date +%F)"
if [ ! -e "$_ud_stamp" ] && command mkdir -p "${_ud_stamp%/*}" 2>/dev/null; then
: >"$_ud_stamp"
usage-digest 2>/dev/null
fi
unset _ud_stamp
fi
# === FZF Setup ===
_fzf_prefix="$(_pixi_env_prefix fzf || true)"
# FZF keybindings (Ctrl+R for history, Ctrl+T for files, Alt+C for cd)
if [ -n "$_fzf_prefix" ] && [ -f "$_fzf_prefix/share/fzf/shell/key-bindings.bash" ]; then
source "$_fzf_prefix/share/fzf/shell/key-bindings.bash"
fi
# FZF completion (trigger with ** and tab)
if [ -n "$_fzf_prefix" ] && [ -f "$_fzf_prefix/share/fzf/shell/completion.bash" ]; then
source "$_fzf_prefix/share/fzf/shell/completion.bash"
fi
unset _fzf_prefix
# FZF default options
export FZF_DEFAULT_OPTS='--height 40% --layout=reverse --border'
# Use fd instead of find if available (faster, respects .gitignore)
if command -v fd &> /dev/null; then
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git'
fi
# === Zoxide Setup ===
if command -v zoxide &> /dev/null; then
eval "$(zoxide init bash)"
fi
# === Broot Setup ===
# Provides the `br` function (broot with cd-on-exit support)
if command -v broot &> /dev/null; then
eval "$(broot --print-shell-function bash)"
fi
# === Forgit Setup ===
_forgit_prefix="$(_pixi_env_prefix forgit || true)"
if [ -n "$_forgit_prefix" ] && [ -f "$_forgit_prefix/share/forgit/forgit.plugin.sh" ]; then
source "$_forgit_prefix/share/forgit/forgit.plugin.sh"
# Fix: plugin expects git-forgit in share/forgit/bin/ but pixi installs it in envs/forgit/bin/
export FORGIT="$_forgit_prefix/bin/git-forgit"
fi
unset _forgit_prefix
# === Flyline ===
# A readline replacement: syntax highlighting, inline history suggestions, a
# fuzzy Ctrl+R, and tab completion synthesised from `--help` output. It is a bash
# *loadable builtin* rather than a program on PATH, which is why it is the one
# tool here that comes from neither pixi nor a script: .chezmoiexternal.toml
# fetches a pinned libflyline.so, and it is loaded with `enable -f`, not sourced.
#
# Below the interactive split, and not merely as a preference. The .so checks for
# an interactive shell itself and refuses to load in one that is not -- printing
# a WARN, an INFO telling you to add the guard, and then bash's own "load
# function for flyline returns failure" on the way out. Three lines of stderr on
# every `ssh host cmd`, scp, and `dl -- claude`, which is the exact noise the
# split above exists to prevent.
#
# `enable flyline` before `enable -f` is upstream's own line and it is not
# redundant: once the .so is in the process, re-enabling the builtin by name
# costs nothing and does not dlopen it a second time, which is what a shell that
# sources this file twice would otherwise do.
_flyline_so="$HOME/.local/lib/flyline/libflyline.so"
if [ -f "$_flyline_so" ] &&
{ enable flyline 2>/dev/null || enable -f "$_flyline_so" flyline 2>/dev/null; }; then
# Mouse capture off. It is on by default and it is the one default here that
# actively fights the rest of this setup: kitty runs `copy_on_select true`
# and herdr runs `ui.copy_on_select`, so drag-to-select IS the clipboard on
# this machine (see the README's Clipboard sections). A prompt that grabs
# the mouse takes that away for the one line you are most likely to want to
# copy. `flyline mouse --mode enabled` per shell to try it.
flyline mouse --mode disabled >/dev/null 2>&1
# Tab completes; the arrows select. Upstream makes Tab a three-way -- it
# opens the list, then walks it, and accepts only when a single match
# survives -- so on the ambiguous completion, which is the whole reason you
# pressed it, the key that everywhere else inserts something moves a cursor
# instead and Enter is what inserts. Up/Down to choose and Tab to take it is
# the readline habit. Shift+Tab still walks and Enter still accepts, so
# upstream's flow stays available to anyone who wants it.
#
# Two bindings, because a list is open before any key is pressed and yet has
# nothing selected in it -- auto-suggest opens it that way, and the popup
# footer says so, reading ` /3` rather than `1/3`. Accepting is a no-op in
# that state, so the first press has to select AND accept as one chain or
# Tab stays dead until something else moves onto an entry. The two guards
# are mutually exclusive, so neither one depends on being bound first.
flyline key bind Tab 'tabCompletionEntrySelected=tabCompletionAcceptEntry'
flyline key bind Tab 'tabCompletionAvailable+!tabCompletionEntrySelected'\
'=tabCompletionNextSuggestion+tabCompletionAcceptEntry'
# Flyline replaces readline outright, and that takes the fzf keybindings set
# above with it: key-bindings.bash installs Ctrl+T and Alt+C through `bind`,
# and nothing consults bash's binding table any more. The widget *functions*
# it defined survive, so the two keys are re-pointed at them through
# flyline's own table -- otherwise they stop working silently, and they are
# two rows of the README cheatsheet.
#
# Ctrl+R is deliberately NOT rebound. Flyline has its own fuzzy history
# search on it, in-process and doing the same job as fzf's, so handing the
# key back to fzf would trade a better implementation for a familiar one.
# Bind it the same way as the other two if that turns out to be wrong:
# flyline key bind Ctrl+r 'always=runBashCommand(__fzf_history__)'
#
# Guarded on the widgets existing rather than assumed: fzf is in the pixi
# floor, but a shell that reached here before the floor was installed would
# otherwise get two keys bound to a missing function.
if [ "$(type -t fzf-file-widget)" = function ]; then
flyline key bind Ctrl+t 'always=runBashCommand(fzf-file-widget)'
fi
if [ "$(type -t __fzf_cd__)" = function ]; then
# __fzf_cd__ only *prints* the `cd <dir>` it built; fzf's own Alt+C
# binding leaned on readline to put that in the buffer. flyline's
# runBashCommand reads the buffer back out of READLINE_LINE/POINT
# instead, so the wrapper writes them and submitOrNewline runs it.
_flyline_fzf_cd() {
local cmd
cmd=$(__fzf_cd__) && READLINE_LINE="$cmd" READLINE_POINT=${#cmd}
}
flyline key bind Alt+c 'always=runBashCommand(_flyline_fzf_cd)+submitOrNewline'
fi
fi
unset _flyline_so
# === Ending a Zellij session by exiting its last pane ===
# Zellij quits when the last pane in a session closes, but the zjstatus bar is
# itself a pane, so a tab is never empty: `exit` in the only shell closes its own
# pane and leaves the session running with nothing in it but the bars, in a window
# that no longer answers `exit`. Zellij has no option for this and no `quit` CLI
# action, so the shell that owns the last pane ends the session itself. Zellij then
# returns, which closes the Kitty window through zjshell and ends an SSH login.
#
# Guarded on an exported marker rather than SHLVL, which is 3 inside a pane and
# would have to be hardcoded: nested shells inherit the marker, so only the shell
# Zellij itself started installs the trap. The trap also fires on the SIGHUP from
# closing a pane with F4, which leaves the same empty session behind.
#
# Only panes running a shell are covered. Nvim and the agents are command panes
# with no shell in them, so a session whose last pane is one of those still has to
# be ended with F7 or Ctrl+; x.
if [ -n "${ZELLIJ:-}" ] && [ -z "${ZJ_PANE_SHELL:-}" ] && [[ $- == *i* ]]; then
export ZJ_PANE_SHELL=$$
_zj_end_session_if_last_pane() {
local panes
# A session rename by another pane leaves this shell's copy of the
# session name stale, and every call below would then hang out its
# timeout instead of working — silently turning this trap into a no-op
# and leaving behind exactly the empty session it exists to prevent.
declare -F _zj_refresh_session_name >/dev/null && _zj_refresh_session_name
# Every `zellij action` is bounded: a client whose session socket has gone
# away waits for it forever rather than failing, and the session shutting
# down underneath us is exactly when this runs — an unbounded call there
# hangs the pane's shell instead of ending it, and orphans the client.
#
# list-panes spans every tab, so a shell in another tab still counts.
panes=$(timeout 2 zellij action list-panes 2>/dev/null) || return 0
# Exactly one terminal pane left means it is this one. Anything else,
# including a query that timed out or answered nothing, leaves the session
# alone: being wrong here would kill panes someone is still using.
[ "$(printf '%s\n' "$panes" | awk '$2 == "terminal"' | wc -l)" -eq 1 ] || return 0
[ -n "${ZELLIJ_SESSION_NAME:-}" ] || return 0
timeout 5 zellij kill-session "$ZELLIJ_SESSION_NAME" >/dev/null 2>&1
}
trap _zj_end_session_if_last_pane EXIT
fi
# === Repairing a renamed session's name ===
# `zjname`, on Ctrl+; o n, renames the session to the project it is working on.
# Panes capture ZELLIJ_SESSION_NAME when they spawn, so every pane that was
# already open is left holding a name that no longer resolves — and that is worse
# than it sounds: `zellij action` from such a pane *hangs* on its socket rather
# than failing, which would turn the last-pane exit trap above into a silent
# no-op and stall anything else that shells out to zellij, zellij-nav's
# Ctrl+hjkl included.
#
# zjname therefore records the name it replaced under $XDG_RUNTIME_DIR, pointing
# at the name that replaced it, and every prompt follows that chain. The common
# case — no rename has happened — is a single file test, so this costs nothing to
# leave running.
if [ -n "${ZELLIJ:-}" ] && [[ $- == *i* ]]; then
# Shared with zjname, which writes what this reads.
_ZJ_NAME_DIR="${XDG_RUNTIME_DIR:-/tmp/zj-$UID}/zellij-names"
_zj_refresh_session_name() {
local mark new i=0
# Bounded rather than `while :`, because the chain is built from files
# this shell does not own: a stale pair left by an interrupted rename
# would otherwise be a hang at every prompt.
while [ "$i" -lt 10 ]; do
mark="$_ZJ_NAME_DIR/${ZELLIJ_SESSION_NAME:-}"
[ -s "$mark" ] || return 0 # empty marker, or none: this name is current
read -r new < "$mark" 2>/dev/null || return 0
[ -n "$new" ] && [ "$new" != "$ZELLIJ_SESSION_NAME" ] || return 0
export ZELLIJ_SESSION_NAME="$new"
i=$((i + 1))
done
}
# Runs first in PROMPT_COMMAND, so it must hand the last command's exit
# status on untouched: anything appended later — a git prompt, starship — is
# entitled to read $? and would otherwise see this function's status instead.
_zj_prompt() {
local status=$?
_zj_refresh_session_name
return "$status"
}
case ";${PROMPT_COMMAND:-};" in
*";_zj_prompt;"*) ;;
*) PROMPT_COMMAND="_zj_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
esac
fi
# Identifies this machine to hosts it SSHes into, for a remote Zellij that opts
# into per-client session names with ZJ_SSH_PER_CLIENT=1. Unused by default — see
# the session-name block below for why one shared "main" is the better default.
#
# LC_ prefixed because sshd's stock AcceptEnv is `LANG LC_*`, so an LC_ variable
# crosses without touching the server's config — the usual trick for propagating
# a value you control to a host you may not. It still needs `SendEnv LC_ZJ_CLIENT`
# on the client side; absent that, this is simply unset on the far end.
#
# Only set when this shell is not itself an SSH login, so that hopping through a
# host does not overwrite the originating client's name with the middle one's.
if [ -z "${SSH_CONNECTION:-}" ]; then
export LC_ZJ_CLIENT="${HOSTNAME%%.*}"
fi
# === Zellij on SSH ===
# An SSH login lands directly in one persistent workspace named "main", so a
# dropped connection costs nothing: reconnecting reattaches the same session
# instead of starting over, and projects are switched inside it with F10.
# Locally Kitty does the same job through `shell zjshell`.
#
# Off by default for the herdr trial, which lands SSH in a plain shell — the same
# shape the earlier tuios trial had. herdr is not autostarted in its place and
# deliberately so: its server is a systemd user unit that is already running, so
# the login has nothing to arrange and `herdr` or `herdr --remote` attaches when
# it is wanted. That is the whole reason this block has the guard list it does —
# autostarting a multiplexer from shell startup fires in contexts that do not
# want one, and every guard below is one of those learned the hard way.
#
# `ZELLIJ_AUTOSTART=1` in ~/.bash_env.local, or in the environment, opts one
# machine back in without touching this file; setting it to 0 was the equivalent
# escape hatch while Zellij was the default.
#
# Deliberately last, so ~/.bash_env.local can set ZELLIJ_AUTOSTART before this is
# reached, and so a fall-through to a plain shell still has the full environment.
#
# Every guard below is a real failure mode rather than defensive noise. Bash
# sources this file for *remote non-interactive* shells too, which is how naive
# versions of this break scp, rsync, and `ssh host <command>`:
# $-, -t scp/rsync/`ssh host cmd` reach here with no interactive tty
# ZELLIJ, TMUX never nest a multiplexer inside itself
# TERM_PROGRAM VS Code and Remote-SSH terminals manage their own tabs
# TERM a dumb terminal cannot host a TUI
# HERDR_ENV a herdr pane is not an SSH login, however much it looks like one
#
# Escape hatch if Zellij itself is ever the problem: `ssh -t <host> 'bash
# --norc -i'` skips this file entirely. A Zellij that fails to start also falls
# through to a normal login shell rather than dropping the connection.
#
# HERDR_ENV is the guard that cost the most to learn. herdr is a multiplexer of
# its own, and a pane it spawns inherits the environment of the SSH login that
# started the herdr client — SSH_CONNECTION included — while setting no ZELLIJ
# and holding a real tty. Every other test above therefore passes, and a herdr
# pane autostarts Zellij as though it were a fresh login.
#
# The damage is not the nesting. When the herdr client goes away its server
# stays up and shrinks the orphaned pane's pty on the way out, down to 4x2 in
# the case that found this; the bash still sitting in that pane then attaches to
# the *shared* `main` session as a second client, and Zellij sizes a session to
# its smallest client. One 4x2 ghost is enough to crush a full-screen session
# with two dozen agents in it, and the pane respawns the attach every few
# seconds, so it re-crushes faster than it can be fixed by hand.
if [ "${ZELLIJ_AUTOSTART:-0}" = 1 ] &&
[ -n "${SSH_CONNECTION:-}" ] &&
[ -z "${ZELLIJ:-}" ] &&
[ -z "${TMUX:-}" ] &&
[ -z "${SSH_ORIGINAL_COMMAND:-}" ] &&
[ "${TERM_PROGRAM:-}" != "vscode" ] &&
[ "${TERM:-dumb}" != "dumb" ] &&
[ -z "${HERDR_ENV:-}" ] &&
[ -t 0 ] && [ -t 1 ] &&
[[ $- == *i* ]] &&
command -v zellij >/dev/null 2>&1; then
# One session named "main" for every device, not one per device.
#
# This was per-client for a while, and per-client is the wrong default: it
# cannot name the *person*. This repo is applied under several usernames and
# every machine has its own hostname, so both $USER and $HOSTNAME differ
# between two devices belonging to one person. Naming the session after
# either therefore guarantees the laptop never finds the desktop's session —
# which defeats the only reason a remote session is persistent at all.
#
# ZJ_SSH_PER_CLIENT=1 restores per-client naming for the case it was actually
# written for: a genuinely shared account, a CI box logged into as one
# service user, where two people attaching to the same session become clients
# of it and Zellij mirrors them onto one screen, annotated "MY FOCUS AND:
# FOCUSED USERS". Nothing is lost, but it is a baffling thing to walk into.
# Opt in per host in ~/.bash_env.local, which is sourced well before this.
#
# ZJ_SSH_SESSION overrides the name outright and wins over both.
_zj_ssh_session=${ZJ_SSH_SESSION:-main}
if [ -z "${ZJ_SSH_SESSION:-}" ] &&
[ "${ZJ_SSH_PER_CLIENT:-0}" = 1 ] &&
[ -n "${LC_ZJ_CLIENT:-}" ]; then
# Sanitised because this becomes a session name and eventually a socket
# path, and a hostname is not guaranteed to be either of those. Falls
# back to "main" when LC_ZJ_CLIENT did not survive the hop, rather than
# inventing a second name for the same client.
_zj_ssh_client=${LC_ZJ_CLIENT//[^A-Za-z0-9_-]/-}
[ -n "$_zj_ssh_client" ] && _zj_ssh_session="main-$_zj_ssh_client"
unset _zj_ssh_client
fi
# Drop a serialized record whose status bar has gone stale, so the attach
# below rebuilds the session from default_layout instead of resurrecting it.
#
# Resurrection replays the serialized layout, and that layout contains the
# whole zjstatus block — because zjstatus can only be configured where it is
# instantiated, its legend is caught in the serialization net alongside the
# tabs, cwds and scrollback that genuinely are session state. A session that
# outlives a change to the bar therefore comes back with the old legend
# permanently, and no `chezmoi apply` can reach it: the file on disk and the
# bar on screen disagree until the record is deleted.
#
# Compared line by line over the format_* keys rather than block against
# block. Those lines are what the bar actually shows, they survive
# serialization verbatim, and every one of them has now been the thing that
# changed — this guard first shipped comparing format_center alone and was
# blind to the very next commit, which moved format_left. A whole-block
# compare is the wrong other extreme: Zellij re-serializes the plugin config
# sorted and re-indented, so it never matches its own source and every login
# would rebuild.
#
# A layout with no format_* keys leaves the record alone, so a broken grep
# cannot turn into a session being deleted on every login.
#
# Deliberately no --force: delete-session refuses on a live session, which is
# exactly the guard wanted here. A dropped connection leaves the server
# running, and reattaching to it is the whole reason this session is
# persistent — an unguarded delete would kill the case this block exists for.
#
# The cache directory is globbed because its middle component is Zellij's
# contract version, which changes from under us on upgrade.
# sed rather than grep -o because the layout aligns these keys into a column
# and the serialized record does not, so the spacing has to be normalised to
# one space before either can be looked for in the other.
_zj_bar=$(sed -nE 's/^[[:space:]]*(format_(left|center|right))[[:space:]]+("[^"]*").*/\1 \3/p' \
"$HOME/.config/zellij/layouts/simple.kdl" 2>/dev/null)
if [ -n "$_zj_bar" ]; then
for _zj_dump in "$HOME"/.cache/zellij/*/session_info/"$_zj_ssh_session"/session-layout.kdl; do
[ -r "$_zj_dump" ] || continue
# Every format_* line the layout has must appear in the record.
# sort -u because a match is counted once however often it occurs.
_zj_want=$(printf '%s\n' "$_zj_bar" | wc -l)
_zj_have=$(grep -oFf <(printf '%s\n' "$_zj_bar") "$_zj_dump" 2>/dev/null |
sort -u | wc -l)
[ "$_zj_want" -eq "$_zj_have" ] && continue
zellij delete-session "$_zj_ssh_session" >/dev/null 2>&1
break
done
fi
unset _zj_bar _zj_dump _zj_want _zj_have
# Detaching exits 0, which ends the SSH session as closing a Kitty window
# does. A non-zero exit means Zellij could not start, so keep the shell.
if zellij attach --create "$_zj_ssh_session"; then
exit
fi
unset _zj_ssh_session
fi