diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml
index d8fc3de..eaffe2c 100644
--- a/.github/workflows/install.yml
+++ b/.github/workflows/install.yml
@@ -54,3 +54,71 @@ jobs:
run: |
test -f nvim/lazy-lock.json
test -d "$HOME/.local/share/nvim/lazy/lazy.nvim"
+
+ # Personal mode on Linux was previously only parse-checked, which is why
+ # linux-extras.sh could ship with an unattended-gpg hang nobody would hit
+ # until a real install. This actually runs it: the apt repositories, the
+ # signing keys and the second-run path.
+ #
+ # Linux only -- the macOS half of personal mode is casks, which need a real
+ # session and are not worth a runner.
+ personal-linux:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v5
+
+ # ubuntu-latest ships the Google Cloud CLI preinstalled, so
+ # linux-extras.sh short-circuits on `command -v gcloud` and its own
+ # install path -- dearmor, sources.list, apt install -- never runs. The
+ # job then "verified" gcloud against the runner's copy, leaving the code
+ # that carried the unattended-gpg bug untested by the job meant to test it.
+ - name: Drop the runner's preinstalled gcloud so the installer's path runs
+ run: |
+ sudo apt-get remove -y google-cloud-cli || true
+ sudo rm -f /usr/share/keyrings/cloud.google.gpg \
+ /etc/apt/sources.list.d/google-cloud-sdk.list
+ ! command -v gcloud
+
+ - name: Run installer via bootstrap (personal mode)
+ run: ./bootstrap.sh personal
+
+ - name: Verify the cask-equivalent tools actually installed
+ run: |
+ for brew_bin in /home/linuxbrew/.linuxbrew/bin/brew "$HOME/.linuxbrew/bin/brew"; do
+ [ -x "$brew_bin" ] && eval "$("$brew_bin" shellenv)" && break
+ done
+ # Claude Code goes to ~/.local/bin, which is not on PATH under bash
+ test -x "$HOME/.local/bin/claude" || { echo "Claude Code missing"; exit 1; }
+ gcloud --version >/dev/null || { echo "gcloud missing"; exit 1; }
+ op --version >/dev/null || { echo "1Password CLI missing"; exit 1; }
+ # Assert the keyrings the installer writes, so a future runner image
+ # preinstalling one of these cannot hollow this job out silently.
+ test -f /usr/share/keyrings/cloud.google.gpg \
+ || { echo "gcloud keyring missing -- installer path did not run"; exit 1; }
+ test -f /usr/share/keyrings/1password-archive-keyring.gpg \
+ || { echo "1Password keyring missing -- installer path did not run"; exit 1; }
+ echo "all personal-mode Linux tools installed by our own code path"
+
+ # Removing the packages while LEAVING the keyrings in place is what makes
+ # this exercise gpg at all. A plain rerun short-circuits on
+ # `command -v gcloud` and never reaches the dearmor step, so the earlier
+ # version of this job could not have caught the unattended-gpg bug.
+ - name: Remove the packages, keep the keyrings
+ run: |
+ sudo apt-get remove -y google-cloud-cli 1password-cli
+ test -f /usr/share/keyrings/cloud.google.gpg
+ test -f /usr/share/keyrings/1password-archive-keyring.gpg
+ ! command -v gcloud
+ ! command -v op
+
+ - name: Rerun over existing keyrings (dearmor must not prompt)
+ run: timeout 900 ./bootstrap.sh personal
+
+ # linux-extras.sh's exit code is swallowed by `|| warning` in install.sh,
+ # so assert the outcome directly or a failure here passes silently.
+ - name: Both tools reinstalled over the existing keyrings
+ run: |
+ gcloud --version >/dev/null || { echo "gcloud missing after rerun"; exit 1; }
+ op --version >/dev/null || { echo "1Password CLI missing after rerun"; exit 1; }
+ echo "dearmor ran unattended over existing keyrings"
diff --git a/Brewfile.core b/Brewfile.core
index 9b523a1..ae62c93 100644
--- a/Brewfile.core
+++ b/Brewfile.core
@@ -29,3 +29,7 @@ brew "neovim"
brew "mise" # polyglot version manager (replaces nvm/pyenv/rbenv)
brew "tmux"
+# --- Dependencies of bin/ scripts (linked onto PATH in both modes) ---
+brew "fswatch" # super-sync: file watching
+brew "rsync" # super-sync: the sync itself (macOS ships an ancient one)
+
diff --git a/Brewfile.personal b/Brewfile.personal
index 0216dec..663c029 100644
--- a/Brewfile.personal
+++ b/Brewfile.personal
@@ -1,6 +1,7 @@
# Brewfile.personal - extras installed ONLY in personal mode.
-# On Linux the cask lines are skipped (see install.sh); install those
-# apps through their vendor instructions instead.
+# On Linux the cask lines are skipped (see install.sh); linux-extras.sh then
+# installs the same tools from their official Linux channels. The Claude
+# desktop app is the exception - see https://code.claude.com/docs/en/desktop-linux
# --- AI tooling ---
cask "claude-code" # Claude Code CLI
diff --git a/README.md b/README.md
index e4b5736..201f0bc 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,13 @@ Corporate mode also prompts for your **work** git email, and nothing personal is
### Linux
-Packages come from [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) on both OSes, so there is one manifest with identical tool versions and no `batcat`/`fdfind` renames. apt is only used to bootstrap Homebrew's build dependencies. Casks (iTerm2, the GUI apps in personal mode) are macOS-only and get skipped; JetBrains Mono Nerd Font is installed from the nerd-fonts release into `~/.local/share/fonts` instead. For the stock GNOME Terminal, set that font in your profile and use [catppuccin/gnome-terminal](https://github.com/catppuccin/gnome-terminal) for the colors. The `install` CI workflow runs corporate mode on both Ubuntu and macOS on every PR.
+Packages come from [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) on both OSes, so there is one manifest with identical tool versions and no `batcat`/`fdfind` renames. apt is only used to bootstrap Homebrew's build dependencies. iTerm2 and the other casks are macOS-only and get skipped; JetBrains Mono Nerd Font is installed from the nerd-fonts release into `~/.local/share/fonts` instead.
+
+**Terminal font is set for you.** `linux-terminal.sh` points the terminal at the nerd font over gsettings, covering Ptyxis (the default from Ubuntu 25.10), GNOME Terminal and Console - the counterpart to the iTerm2 dynamic profile on macOS. A font you chose yourself is left alone; force it with `DOTFILES_TERMINAL_FONT`, which also overrides the font that gets set. Quit the terminal completely afterwards: a running one caches the font list from startup, so a new tab still shows tofu. For colors, Ptyxis ships palettes in its preferences; GNOME Terminal users can use [catppuccin/gnome-terminal](https://github.com/catppuccin/gnome-terminal).
+
+**Personal mode installs the cask tools too.** Since `cask` lines are stripped on Linux, `linux-extras.sh` installs Claude Code (native installer), the gcloud CLI and the 1Password CLI from their official Linux channels, so personal mode is not quietly thinner than on macOS. The Claude desktop app is the one exception - see [desktop on Linux](https://code.claude.com/docs/en/desktop-linux).
+
+The `install` CI workflow runs corporate mode on both Ubuntu and macOS on every PR, plus personal mode on Ubuntu - the only coverage for `linux-extras.sh`.
## What's where
@@ -71,6 +77,8 @@ Brewfile.macos # macOS-only: iTerm2, nerd font cask, mas
Brewfile.personal # extras for personal mode
install.sh # installer (brew, symlinks, git identity, secrets, theme caches)
macos.sh # opt-in macOS system defaults (--with-macos-defaults)
+linux-terminal.sh # sets the nerd font in Ptyxis/GNOME Terminal/Console
+linux-extras.sh # Linux installs for the personal-mode cask tools
iterm2/ # dynamic profile (Dracula+ colors + nerd font, auto-loaded)
zsh/ # .zshrc + antidote plugin list
starship/ # prompt config (catppuccin mocha powerline)
diff --git a/bin/gifenc b/bin/gifenc
index 1cd459e..986b0cf 100755
--- a/bin/gifenc
+++ b/bin/gifenc
@@ -1,21 +1,45 @@
#!/bin/sh
+# High-quality GIF from a video, via a per-clip colour palette.
# From http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html
+#
+# Usage: gifenc [output.gif] FPS=15 gifenc clip.mov
+set -eu
+
+if [ $# -lt 1 ]; then
+ echo "Usage: $0 [output.gif]" >&2
+ exit 1
+fi
in="$1"
-out="$2"
+out="${2:-}"
+
+[ -f "$in" ] || { echo "No such file: $in" >&2; exit 1; }
if [ -z "$out" ]; then
- out="$(echo $in | cut -f 1 -d '.' ).gif"
+ # Strip only the final extension. `cut -f1 -d.` cut at the FIRST dot, so
+ # my.clip.mov became my.gif, and any dot in a parent directory truncated
+ # the path outright.
+ out="${in%.*}.gif"
fi
-palette="/tmp/palette.png"
+# A private directory, not `mktemp -t `: GNU substitutes the X's and
+# keeps the .png suffix, but BSD/macOS treats the whole argument as a prefix and
+# appends its own suffix, leaving a file that does not end in .png -- ffmpeg
+# infers the palettegen muxer from the extension, so it would fail there.
+# A fixed name inside a private directory sidesteps the difference, and also
+# fixes the original /tmp/palette.png, which raced between concurrent runs and
+# was a predictable-name write on multi-user machines.
+tmpdir="$(mktemp -d)"
+trap 'rm -rf "$tmpdir"' EXIT INT TERM
+palette="$tmpdir/palette.png"
fps=${FPS:-30}
-
filters="fps=$fps,scale=500:-1:flags=lanczos"
-ffmpeg -v warning -i $in -vf "$filters,palettegen" -y $palette
-ffmpeg -v warning -i $in -i $palette -lavfi "$filters [x]; [x][1:v] paletteuse" -y $out
+# Everything quoted: this script exists for screen recordings, whose filenames
+# reliably contain spaces ("Screen Recording 2026-08-05 at 10.15.32.mov").
+ffmpeg -v warning -i "$in" -vf "$filters,palettegen" -y "$palette"
+ffmpeg -v warning -i "$in" -i "$palette" -lavfi "$filters [x]; [x][1:v] paletteuse" -y "$out"
echo
-ls -lh $out
+ls -lh "$out"
diff --git a/install.sh b/install.sh
index b997fa8..a449ce1 100755
--- a/install.sh
+++ b/install.sh
@@ -27,6 +27,22 @@ case "$OSTYPE" in
*) fail "Unsupported OS: $OSTYPE" ;;
esac
+# The documented install is `curl ... | bash -s -- personal`, where stdin is the
+# curl pipe for the whole run -- so `[[ -t 0 ]]` is false even with a user sat
+# right there. Ask the terminal directly instead. Anything that prompts must use
+# this and read from /dev/tty, or it silently takes the non-interactive path on
+# the exact command the README recommends.
+#
+# Actually open /dev/tty rather than just testing that it exists: on CI runners
+# and in containers the device node is present but has no controlling terminal,
+# so the open fails -- and a failed redirect on `read` would abort the whole
+# installer under `set -e`.
+# No `[[ -t 0 ]] && return 0` shortcut: every caller reads from /dev/tty, so
+# what matters is whether that open succeeds, not whether stdin is a terminal.
+# Returning early on the stdin check could green-light a redirect that then
+# fails, aborting the installer under set -e.
+have_tty() { (exec 3/dev/null; }
+
# ----------------------------------------
# Mode selection
# ----------------------------------------
@@ -43,11 +59,11 @@ for arg in "$@"; do
esac
done
if [[ -z "$MODE" ]]; then
- if [[ -t 0 ]]; then
+ if have_tty; then
echo "Select installation mode:"
echo " 1) personal - everything: core tools + AI, cloud, media"
echo " 2) corporate - restricted: core CLI tools only"
- read -rp "Mode [1/2]: " choice
+ read -rp "Mode [1/2]: " choice /dev/null 2>&1; then
if [[ "$OS" == "linux" ]] && command -v apt-get >/dev/null 2>&1; then
info "Installing Homebrew build dependencies via apt..."
+ # gnupg is not a Homebrew dependency, but linux-extras.sh needs it to
+ # dearmor the gcloud and 1Password repository keys, and minimal images
+ # do not ship it.
if ! (sudo apt-get update -qq && sudo apt-get install -y -qq \
- build-essential procps curl file git zsh fontconfig); then
+ build-essential procps curl file git zsh fontconfig gnupg); then
warning "apt dependencies failed; Homebrew install may not work"
fi
fi
@@ -84,8 +103,30 @@ fi
for brew_bin in /opt/homebrew/bin/brew /home/linuxbrew/.linuxbrew/bin/brew "$HOME/.linuxbrew/bin/brew" /usr/local/bin/brew; do
if [[ -x "$brew_bin" ]]; then
eval "$("$brew_bin" shellenv)"
- grep -q 'brew shellenv' "$HOME/.zprofile" 2>/dev/null || \
- echo "eval \"\$($brew_bin shellenv)\"" >> "$HOME/.zprofile"
+ shellenv_line="eval \"\$($brew_bin shellenv)\""
+ # Match this exact brew, not any 'brew shellenv' line. Migrating Intel ->
+ # ARM (/usr/local -> /opt/homebrew), or /home/linuxbrew -> ~/.linuxbrew,
+ # otherwise leaves the stale line in place and never adds the right one,
+ # producing a broken login shell that re-running cannot repair.
+ if ! grep -qxF "$shellenv_line" "$HOME/.zprofile" 2>/dev/null; then
+ # Drop a shellenv line pointing at a different prefix. Anchored to the
+ # exact shape the installer writes, so a line a user wrote themselves that
+ # merely mentions "brew shellenv" is left alone.
+ stale_re='^[[:space:]]*eval "\$\(.*brew shellenv\)"[[:space:]]*$'
+ if [[ -f "$HOME/.zprofile" ]] && grep -qE "$stale_re" "$HOME/.zprofile"; then
+ mkdir -p "$BACKUP_DIR"
+ cp "$HOME/.zprofile" "$BACKUP_DIR/.zprofile"
+ # `grep -v` exits 1 when it filters out every line, which is exactly the
+ # single-line .zprofile the old installer wrote -- so `&& mv` silently
+ # skipped, leaving the stale line and an orphaned .tmp behind.
+ grep -vE "$stale_re" "$HOME/.zprofile" > "$HOME/.zprofile.tmp" || true
+ mv "$HOME/.zprofile.tmp" "$HOME/.zprofile"
+ warning "Replaced a stale brew shellenv line in ~/.zprofile (backed up)"
+ fi
+ # printf, not echo: a file with no trailing newline would otherwise get
+ # this appended to its last line, silently corrupting both.
+ printf '\n%s\n' "$shellenv_line" >> "$HOME/.zprofile"
+ fi
break
fi
done
@@ -134,6 +175,18 @@ link() {
mkdir -p "$BACKUP_DIR/$(dirname "$rel")"
cp -r "$target" "$BACKUP_DIR/$rel" || fail "Could not back up $target, aborting before overwriting it"
rm -rf "$target"
+ elif [[ -L "$target" ]]; then
+ # An existing symlink is someone else's config -- stow, chezmoi, another
+ # dotfiles repo. ln -sfn would replace it with nothing recorded, so note
+ # where it pointed. The link itself is cheap to recreate; knowing the
+ # target is the part that is lost otherwise.
+ local current rel
+ current="$(readlink "$target")"
+ if [[ "$current" != "$source" ]]; then
+ rel="${target#"$HOME"/}"
+ mkdir -p "$BACKUP_DIR"
+ printf '%s -> %s\n' "$rel" "$current" >> "$BACKUP_DIR/replaced-symlinks.txt"
+ fi
fi
mkdir -p "$(dirname "$target")"
ln -sfn "$source" "$target"
@@ -172,7 +225,13 @@ mkdir -p "$HOME/.local/bin"
for script in "$DOTFILES_DIR"/bin/*; do
[[ -f "$script" ]] || continue
name="$(basename "$script")"
- [[ "$MODE" == "corporate" && "$name" == "gifenc" ]] && continue
+ # gifenc needs ffmpeg, which only personal mode installs. Switching
+ # personal -> corporate must remove the link, not just skip creating it,
+ # or a live symlink to a broken script survives the mode change.
+ if [[ "$MODE" == "corporate" && "$name" == "gifenc" ]]; then
+ [[ -L "$HOME/.local/bin/$name" ]] && rm -f "$HOME/.local/bin/$name"
+ continue
+ fi
ln -sfn "$script" "$HOME/.local/bin/$name"
done
success "Linked bin/ scripts to ~/.local/bin"
@@ -181,7 +240,12 @@ if [[ -f "$HOME/.gitignore_global" ]]; then
warning "The old ~/.gitignore_global is no longer used; global ignores now live in ~/.config/git/ignore"
fi
-[[ -d "$BACKUP_DIR" ]] && info "Previous configs backed up to $BACKUP_DIR"
+# Only claim a backup when something other than the symlink ledger is in there.
+if [[ -d "$BACKUP_DIR" ]] && [[ -n "$(find "$BACKUP_DIR" -mindepth 1 ! -name 'replaced-symlinks.txt' -print -quit)" ]]; then
+ info "Previous configs backed up to $BACKUP_DIR"
+fi
+[[ -f "$BACKUP_DIR/replaced-symlinks.txt" ]] && \
+ info "Replaced symlinks recorded in $BACKUP_DIR/replaced-symlinks.txt"
# ----------------------------------------
# Nerd Font (Linux; macOS gets it as a cask)
@@ -189,7 +253,10 @@ fi
if [[ "$OS" == "linux" ]] && command -v fc-cache >/dev/null 2>&1; then
FONT_DIR="$HOME/.local/share/fonts/JetBrainsMonoNerdFont"
- if [[ ! -d "$FONT_DIR" ]]; then
+ # Check for actual font files, not just the directory: if a previous run
+ # created the directory and then failed to extract, testing -d alone would
+ # skip the install forever and leave the prompt rendering as tofu.
+ if ! compgen -G "$FONT_DIR/*.ttf" >/dev/null 2>&1; then
info "Installing JetBrains Mono Nerd Font..."
font_tmp="$(mktemp -d)"
if curl -fsSL -o "$font_tmp/JetBrainsMono.tar.xz" \
@@ -197,26 +264,50 @@ if [[ "$OS" == "linux" ]] && command -v fc-cache >/dev/null 2>&1; then
&& mkdir -p "$FONT_DIR" \
&& tar -xJf "$font_tmp/JetBrainsMono.tar.xz" -C "$FONT_DIR"; then
fc-cache -f "$FONT_DIR" >/dev/null || true
- success "Nerd Font installed (set it in your terminal profile)"
+ success "Nerd Font installed"
else
warning "Font download failed; install a Nerd Font manually for prompt icons"
fi
rm -rf "$font_tmp"
+ else
+ success "Nerd Font already installed"
fi
fi
+# Point the terminal at the font. Deliberately outside the fc-cache check above:
+# installing font files and configuring the terminal are independent, and
+# fontconfig is only apt-installed when Homebrew was missing -- so a machine
+# that already had brew would otherwise never get its terminal configured.
+# Exit code matters here: 0 configured, 1 failed, 2 nothing to configure.
+# Treating any non-failure as success reported "Terminal font is configured" on
+# headless boxes that had printed "No supported terminal found" moments earlier.
+TERMINAL_CONFIGURED=0
+if [[ "$OS" == "linux" && -x "$DOTFILES_DIR/linux-terminal.sh" ]]; then
+ "$DOTFILES_DIR/linux-terminal.sh" && terminal_rc=0 || terminal_rc=$?
+ case "$terminal_rc" in
+ 0) TERMINAL_CONFIGURED=1 ;;
+ 2) : ;; # nothing to configure; next-steps tells the user what to set
+ *) warning "Terminal font setup failed; set it manually" ;;
+ esac
+fi
+
# ----------------------------------------
# Git identity (kept out of the repo)
# ----------------------------------------
if [[ ! -f "$HOME/.gitconfig.local" ]]; then
info "Setting up git identity (${MODE} mode)..."
- if [[ -t 0 ]]; then
- read -rp "Git name: " git_name
+ # Read from /dev/tty, not stdin: on the piped bootstrap path stdin is the
+ # script itself. Without this the prompt is skipped, no ~/.gitconfig.local is
+ # written, and since git/.gitconfig includes it unconditionally git falls back
+ # to a guessed user@hostname -- committing under a wrong identity rather than
+ # failing loudly.
+ if have_tty; then
+ read -rp "Git name: " git_name "$HOME/.gitconfig.local"
success "Wrote ~/.gitconfig.local"
@@ -259,10 +350,36 @@ if [[ ! -f "$HOME/.config/btop/btop.conf" ]]; then
success "btop config created"
fi
-if [[ ! -d "$HOME/.tmux/plugins/tpm" ]]; then
+# Test for the .git directory, not the directory itself: an interrupted clone
+# leaves an empty or partial directory that would skip this forever, the same
+# trap as the nerd font check. bootstrap.sh already tests .git this way.
+if [[ ! -d "$HOME/.tmux/plugins/tpm/.git" ]]; then
info "Installing tmux plugin manager..."
- git clone --depth 1 https://github.com/tmux-plugins/tpm "$HOME/.tmux/plugins/tpm"
- success "TPM installed (prefix + I inside tmux installs plugins)"
+ # Move a non-git tpm aside rather than deleting it: it may be a tarball or
+ # package install rather than a half-finished clone. Guarded like the clone
+ # below -- an unguarded mv would abort the installer under set -e before
+ # Neovim, the extras and chsh.
+ tpm_ready=1
+ if [[ -e "$HOME/.tmux/plugins/tpm" ]]; then
+ mkdir -p "$BACKUP_DIR/.tmux/plugins"
+ if mv "$HOME/.tmux/plugins/tpm" "$BACKUP_DIR/.tmux/plugins/tpm"; then
+ warning "Moved an existing non-git tpm to $BACKUP_DIR/.tmux/plugins/tpm"
+ else
+ # The clone would fail anyway with the directory still in place, so skip
+ # it rather than emit a second, more confusing error.
+ warning "Could not move the existing tpm aside; leaving it in place"
+ tpm_ready=0
+ fi
+ fi
+ # Guarded like every other network call here: a proxy or a GitHub blip should
+ # not abort the installer before mise, Neovim, the extras and chsh.
+ if [[ "$tpm_ready" == 1 ]]; then
+ if git clone --depth 1 https://github.com/tmux-plugins/tpm "$HOME/.tmux/plugins/tpm"; then
+ success "TPM installed (prefix + I inside tmux installs plugins)"
+ else
+ warning "Could not clone TPM; tmux will install it on first launch"
+ fi
+ fi
fi
if command -v tldr >/dev/null 2>&1; then
@@ -293,6 +410,14 @@ if command -v nvim >/dev/null 2>&1; then
success "Neovim ready"
fi
+# Personal mode on macOS installs Claude Code, gcloud and the 1Password CLI as
+# casks. Those lines are stripped on Linux, so install the same tools from the
+# vendors' Linux channels to keep the two platforms at parity.
+if [[ "$MODE" == "personal" && "$OS" == "linux" && -x "$DOTFILES_DIR/linux-extras.sh" ]]; then
+ info "Installing personal-mode tools that are casks on macOS..."
+ "$DOTFILES_DIR/linux-extras.sh" || warning "Some Linux extras failed; see the output above"
+fi
+
if [[ "$WITH_MACOS_DEFAULTS" == 1 && "$OS" == "macos" ]]; then
info "Applying macOS defaults..."
if "$DOTFILES_DIR/macos.sh"; then success "macOS defaults applied"; else warning "macos.sh failed"; fi
@@ -310,9 +435,24 @@ if [[ "$WITH_MAC_KEYS" == 1 && "$OS" == "linux" ]]; then
fi
fi
-if [[ "$SHELL" != *zsh ]] && command -v zsh >/dev/null 2>&1; then
- info "Setting zsh as default shell..."
+# $SHELL reflects the shell that happens to be running, which is not the login
+# shell inside editors, CI or a nested bash. Read the account record instead --
+# getent on Linux, dscl on macOS, which has no getent. Both are guarded because
+# a missing command would otherwise take the whole script down under `set -e`.
+LOGIN_SHELL=""
+if command -v getent >/dev/null 2>&1; then
+ LOGIN_SHELL="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)" || LOGIN_SHELL=""
+elif command -v dscl >/dev/null 2>&1; then
+ LOGIN_SHELL="$(dscl . -read "/Users/$(id -un)" UserShell 2>/dev/null | awk '{print $2}')" || LOGIN_SHELL=""
+fi
+LOGIN_SHELL="${LOGIN_SHELL:-$SHELL}"
+if [[ "$LOGIN_SHELL" == *zsh ]]; then
+ success "Login shell already zsh"
+elif command -v zsh >/dev/null 2>&1; then
+ info "Setting zsh as default shell (currently $LOGIN_SHELL)..."
chsh -s "$(command -v zsh)" || warning "Could not change the default shell; run chsh manually"
+else
+ warning "zsh is not installed; login shell left as $LOGIN_SHELL"
fi
# ----------------------------------------
@@ -327,17 +467,31 @@ echo " 1. Restart your terminal"
if [[ "$OS" == "macos" ]]; then
echo " 2. iTerm2: Settings > Profiles > 'dotfiles' > Other Actions > Set as Default"
echo " (Dracula+ colors and the nerd font are baked into the profile)"
+elif [[ "$TERMINAL_CONFIGURED" == 1 ]]; then
+ echo " 2. Terminal font is configured. Quit the terminal completely and reopen"
+ echo " -- a new tab is not enough, since a running terminal caches the font"
+ echo " list from startup."
else
echo " 2. Set your terminal font to JetBrainsMono Nerd Font"
- echo " (GNOME Terminal catppuccin: github.com/catppuccin/gnome-terminal)"
fi
echo " 3. Import history: atuin import zsh"
if [[ "$MODE" == "personal" ]]; then
- if [[ "$OS" == "macos" ]]; then
- echo " 4. Sign in: claude / gcloud auth login / op signin"
- else
- echo " 4. Casks are macOS-only: install Claude Code, gcloud and the"
- echo " 1Password CLI from their vendor instructions, then sign in"
+ # Only name tools that are actually here: on Linux the apt-based ones are
+ # skipped when sudo is unavailable, and telling someone to run a command
+ # they do not have is worse than saying nothing.
+ signins=()
+ { command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; } && signins+=("claude")
+ command -v gcloud >/dev/null 2>&1 && signins+=("gcloud auth login")
+ command -v op >/dev/null 2>&1 && signins+=("op signin")
+ if [[ ${#signins[@]} -gt 0 ]]; then
+ # "${arr[*]}" joins on the FIRST character of IFS only, so IFS=' / ' gave
+ # "claude gcloud auth login op signin" -- unreadable, since the entries
+ # themselves contain spaces.
+ printf -v joined '%s / ' "${signins[@]}"
+ echo " 4. Sign in: ${joined% / }"
+ fi
+ if [[ "$OS" == "linux" ]]; then
+ echo " Claude desktop app: https://code.claude.com/docs/en/desktop-linux"
fi
fi
echo ""
diff --git a/linux-extras.sh b/linux-extras.sh
new file mode 100755
index 0000000..68f1ac7
--- /dev/null
+++ b/linux-extras.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+#
+# Personal-mode tools that ship as Homebrew casks on macOS.
+#
+# install.sh strips `cask` lines on Linux, so personal mode there silently ends
+# up without Claude Code, the gcloud CLI and the 1Password CLI -- the installer
+# just prints "install these yourself". This installs them from the vendors'
+# official Linux channels instead.
+#
+# Deliberately NOT covered:
+# claude (desktop app) - see https://code.claude.com/docs/en/desktop-linux
+#
+# Exits non-zero if a tool actually failed to install, so the caller can report
+# it. Tools that are already present, or skipped for want of sudo, are not
+# failures.
+set -uo pipefail
+
+info() { echo -e "\033[0;34m[INFO]\033[0m $1"; }
+success() { echo -e "\033[0;32m[OK]\033[0m $1"; }
+warning() { echo -e "\033[1;33m[!]\033[0m $1"; }
+
+failures=0
+
+command -v apt-get >/dev/null 2>&1 || { warning "Not a Debian/Ubuntu system; skipping Linux extras"; exit 0; }
+
+# The documented greenfield path is `curl ... | bash -s -- personal`, where
+# stdin is the curl pipe all the way through install.sh -- so `[[ -t 0 ]]` is
+# false even though a user is sitting there. Ask the terminal directly, which is
+# what makes the advertised one-liner actually install these.
+#
+# Open /dev/tty rather than testing that it exists: on CI runners and in
+# containers the device node is present with no controlling terminal. Same probe
+# as install.sh's have_tty().
+have_sudo() {
+ sudo -n true 2>/dev/null && return 0
+ (exec 3/dev/null || return 1
+ sudo -v /dev/null
+}
+
+# Both apt repositories below need gpg to dearmor their signing keys, and
+# neither bootstrap.sh nor install.sh guarantees gnupg on a minimal image.
+ensure_gnupg() {
+ command -v gpg >/dev/null 2>&1 && return 0
+ info "Installing gnupg (required to verify the repository keys)..."
+ sudo apt-get update -qq && sudo apt-get install -y -qq gnupg
+}
+
+# --- Claude Code -----------------------------------------------------------
+# Native installer; lands in ~/.local/bin/claude and self-updates.
+# https://code.claude.com/docs/en/setup
+#
+# ~/.local/bin is put on PATH by zsh/.zshrc, but this script runs under bash,
+# so check the path directly as well or a re-run downloads it again.
+if command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; then
+ success "Claude Code already installed"
+else
+ info "Installing Claude Code..."
+ if curl -fsSL https://claude.ai/install.sh | bash; then
+ success "Claude Code installed (run 'claude' to sign in)"
+ else
+ warning "Claude Code install failed; see https://code.claude.com/docs/en/setup"
+ failures=$((failures + 1))
+ fi
+fi
+
+# --- Google Cloud CLI ------------------------------------------------------
+# https://docs.cloud.google.com/sdk/docs/install
+if command -v gcloud >/dev/null 2>&1; then
+ success "gcloud already installed"
+elif ! have_sudo; then
+ warning "sudo unavailable; skipping gcloud (needs an apt repository)"
+else
+ info "Installing Google Cloud CLI..."
+ # --batch --yes: without them gpg prompts before overwriting an existing
+ # keyring and blocks the installer. The keyring outlives the package, so this
+ # fires on any re-run after a failed apt step, after `apt remove`, or for
+ # anyone who already followed Google's docs by hand.
+ if ensure_gnupg &&
+ curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
+ | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/cloud.google.gpg &&
+ echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \
+ | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null &&
+ sudo apt-get update -qq && sudo apt-get install -y -qq google-cloud-cli; then
+ success "gcloud installed (run 'gcloud auth login')"
+ else
+ warning "gcloud install failed; see https://docs.cloud.google.com/sdk/docs/install"
+ failures=$((failures + 1))
+ fi
+fi
+
+# --- 1Password CLI ---------------------------------------------------------
+# https://www.1password.dev/cli/get-started/
+if command -v op >/dev/null 2>&1; then
+ success "1Password CLI already installed"
+elif ! have_sudo; then
+ warning "sudo unavailable; skipping 1Password CLI (needs an apt repository)"
+else
+ info "Installing 1Password CLI..."
+ arch="$(dpkg --print-architecture)"
+ if ensure_gnupg &&
+ curl -sS https://downloads.1password.com/linux/keys/1password.asc \
+ | sudo gpg --batch --yes --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg &&
+ echo "deb [arch=$arch signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$arch stable main" \
+ | sudo tee /etc/apt/sources.list.d/1password.list >/dev/null &&
+ sudo mkdir -p /etc/debsig/policies/AC2D62742012EA22/ &&
+ curl -sS https://downloads.1password.com/linux/debian/debsig/1password.pol \
+ | sudo tee /etc/debsig/policies/AC2D62742012EA22/1password.pol >/dev/null &&
+ sudo mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22 &&
+ curl -sS https://downloads.1password.com/linux/keys/1password.asc \
+ | sudo gpg --batch --yes --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg &&
+ sudo apt-get update -qq && sudo apt-get install -y -qq 1password-cli; then
+ success "1Password CLI installed (run 'op signin')"
+ else
+ warning "1Password CLI install failed; see https://www.1password.dev/cli/get-started/"
+ failures=$((failures + 1))
+ fi
+fi
+
+info "Claude desktop app on Linux: https://code.claude.com/docs/en/desktop-linux"
+exit $(( failures > 0 ? 1 : 0 ))
diff --git a/linux-terminal.sh b/linux-terminal.sh
new file mode 100755
index 0000000..87f9866
--- /dev/null
+++ b/linux-terminal.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+#
+# Point the terminal at the nerd font on Linux.
+#
+# macOS gets this for free: install.sh links an iTerm2 dynamic profile with the
+# font baked in. On Linux the font was only ever a printed instruction, so a
+# fresh machine renders the starship powerline prompt as tofu boxes until the
+# user finds the setting themselves.
+#
+# Terminal emulators here are configured through dconf/gsettings rather than
+# files, so this sets the font directly.
+#
+# An existing custom font is left alone. The iTerm2 profile on macOS is a
+# separate profile the user opts into, so it never overwrites their own; doing
+# the equivalent here means not touching a terminal that already has a
+# deliberate font set. Force a change with DOTFILES_TERMINAL_FONT.
+#
+# Supported: Ptyxis (Ubuntu 25.10+ default), GNOME Terminal, Console/Kgx.
+#
+# Exit codes, so the caller can tell the three states apart rather than reading
+# "not a failure" as "configured":
+# 0 a terminal now has a nerd font
+# 1 a terminal was found but setting its font failed
+# 2 nothing to configure (no gsettings, no supported terminal, or the user's
+# own font is kept and may not be a nerd font)
+set -uo pipefail
+
+FONT="${DOTFILES_TERMINAL_FONT:-JetBrainsMono Nerd Font Mono 12}"
+FORCED="${DOTFILES_TERMINAL_FONT:+yes}" # explicit request overrides a custom font
+
+info() { echo -e "\033[0;34m[INFO]\033[0m $1"; }
+success() { echo -e "\033[0;32m[OK]\033[0m $1"; }
+warning() { echo -e "\033[1;33m[!]\033[0m $1"; }
+
+if ! command -v gsettings >/dev/null 2>&1; then
+ info "gsettings not available; skipping terminal font setup"
+ exit 2
+fi
+
+failures=0
+configured=0
+
+# Capture first, then match. Piping into `grep -q` under `set -o pipefail` is a
+# trap: grep exits at the first match, gsettings dies with SIGPIPE, and pipefail
+# reports the whole pipeline as failed even though the match succeeded.
+has_schema() {
+ local schemas
+ schemas="$(gsettings list-schemas 2>/dev/null)" || return 1
+ [[ $'\n'"$schemas"$'\n' == *$'\n'"$1"$'\n'* ]]
+}
+# Key names differ between terminals and versions, so confirm a key exists
+# before setting it rather than relying on the set failing cleanly.
+has_key() {
+ local keys
+ keys="$(gsettings list-keys "$1" 2>/dev/null)" || return 1
+ [[ $'\n'"$keys"$'\n' == *$'\n'"$2"$'\n'* ]]
+}
+
+# apply