summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/alacritty-mosh61
-rw-r--r--bin/bash-string-expansion.sh12
-rwxr-xr-xbin/default-browserbin0 -> 168112 bytes
-rwxr-xr-xbin/dotfiles-sync34
-rwxr-xr-xbin/ghostty-reload6
-rwxr-xr-xbin/mosh-tmux.sh17
-rwxr-xr-xbin/neovide-fix.sh36
-rwxr-xr-xbin/notify12
-rwxr-xr-xbin/open302
-rwxr-xr-xbin/pyexec27
-rwxr-xr-xbin/random-id.ts105
-rwxr-xr-xbin/smack5
-rwxr-xr-xbin/sz-rm-meta.sh477
-rwxr-xr-xbin/sz-wait-for308
-rwxr-xr-xbin/terminal-set-opacity32
-rwxr-xr-xbin/theme554
-rwxr-xr-xbin/tmux-colors.zsh12
-rwxr-xr-xbin/truecolour-test21
-rwxr-xr-xbin/vi27
-rwxr-xr-xbin/wifi33
-rwxr-xr-xbin/zsh-colors.zsh39
21 files changed, 2120 insertions, 0 deletions
diff --git a/bin/alacritty-mosh b/bin/alacritty-mosh
new file mode 100755
index 0000000..6032828
--- /dev/null
+++ b/bin/alacritty-mosh
@@ -0,0 +1,61 @@
+#!/bin/bash
+
+set -euo pipefail
+
+target="$1"
+dedicated_dir='/tmp/alacritty'
+dedicated_cfg="${dedicated_dir}/$target.conf"
+dedicated_sh="${dedicated_dir}/${target}.sh"
+mkdir -p "${dedicated_dir}"
+
+main_cfg="${HOME}/.config/alacritty/alacritty.toml"
+
+# Alacritty loads config files in a way that doesn't allow terminal.shell to be
+# overriden, so this doesn't work.
+# cat > "${dedicated_cfg}" <<EOF
+# # [general]
+# # import = [
+# # "${main_cfg}",
+# # ]
+# [terminal.shell]
+# args = ["--login", "-ic", "${dedicated_sh}"]
+# program = "/bin/zsh"
+# [window]
+# title = "${target}"
+# dynamic_title = false
+# EOF
+
+awk -v r='terminal.shell,window' '
+/^\[/ {
+ s = $0
+ gsub(/^\[|\]$/, "", s)
+ p = (index(","r",", ","s",") == 0)
+}
+p
+' "${main_cfg}" > "${dedicated_cfg}"
+cat >> "${dedicated_cfg}" <<EOF
+[terminal.shell]
+args = ["--login", "-ic", "${dedicated_sh}"]
+program = "/bin/zsh"
+
+[window]
+title = "${target}"
+dynamic_title = false
+opacity = 0.96
+# blur = true
+decorations = "buttonless"
+dynamic_padding = true
+option_as_alt = "None"
+EOF
+
+cat > "$dedicated_sh" <<EOF
+#!/bin/bash
+cleanup() {
+ rm -f "${dedicated_cfg}"
+ rm -f "${dedicated_sh}"
+}
+trap cleanup EXIT INT TERM
+mosh ${target} -- tmux new-session -As 0
+EOF
+chmod +x "$dedicated_sh"
+open -n -a Alacritty --args --config-file "${dedicated_cfg}"
diff --git a/bin/bash-string-expansion.sh b/bin/bash-string-expansion.sh
new file mode 100644
index 0000000..7e6ba28
--- /dev/null
+++ b/bin/bash-string-expansion.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+set -euo pipefail
+
+cat <<EOF
+\${var#substring} = ${1#$2}
+\${var##substring} = ${1##$2}
+\${var%substring} = ${1%$2}
+\${var%%substring} = ${1%%$2}
+EOF
+
+
diff --git a/bin/default-browser b/bin/default-browser
new file mode 100755
index 0000000..f5a5d4c
--- /dev/null
+++ b/bin/default-browser
Binary files differ
diff --git a/bin/dotfiles-sync b/bin/dotfiles-sync
new file mode 100755
index 0000000..503a1ed
--- /dev/null
+++ b/bin/dotfiles-sync
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# Transitional, added 2026-09-17: the sync job moved to cloud-setup, whose
+# svc/setup.sh points each host's unit at its own copy. A host whose unit still
+# runs this path -- one that was offline when the units were regenerated --
+# lands here: bring its cloud-setup clone up to date and hand over, so it keeps
+# receiving until someone runs `dotfiles-sync-enable` there. Remove once no unit
+# points at this file.
+
+set -euo pipefail
+
+cloud_setup="$HOME/src/soarez/cloud-setup"
+target="$cloud_setup/bin/dotfiles-sync"
+
+if [[ ! -x "$target" && -d "$cloud_setup/.git" ]]
+then
+ umask 022
+ git -C "$cloud_setup" fetch --prune origin && git -C "$cloud_setup" merge --ff-only --quiet origin/master || true
+fi
+
+if [[ -x "$target" ]]
+then
+ exec "$target" "$@"
+fi
+
+>&2 cat <<MSG
+dotfiles-sync has moved to cloud-setup, and this host has no usable copy at
+$target. To recover:
+
+ git -C $cloud_setup pull --ff-only
+ $(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)/link.sh yes
+ dotfiles-sync-enable
+MSG
+exit 1
diff --git a/bin/ghostty-reload b/bin/ghostty-reload
new file mode 100755
index 0000000..b4ce47f
--- /dev/null
+++ b/bin/ghostty-reload
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# Trigger Ghostty config reload
+pkill -SIGUSR2 ghostty
diff --git a/bin/mosh-tmux.sh b/bin/mosh-tmux.sh
new file mode 100755
index 0000000..55bf295
--- /dev/null
+++ b/bin/mosh-tmux.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 1 ]]; then
+ >&2 cat <<EOF
+Usage:
+
+ $0 <hostname>
+EOF
+ exit 1
+fi
+
+target="${1}"
+
+mosh "sz@$target" -- tmux new-session -As 0
+
diff --git a/bin/neovide-fix.sh b/bin/neovide-fix.sh
new file mode 100755
index 0000000..f8d92ad
--- /dev/null
+++ b/bin/neovide-fix.sh
@@ -0,0 +1,36 @@
+#!/bin/zsh
+# Make Neovide.app start through a wrapper that sources ~/.zshrc, so the GUI
+# app sees the same PATH and environment as a terminal, then re-sign the app.
+#
+# Re-run after every Neovide update: the update replaces the app bundle and
+# undoes this. Needs sudo, for the re-signing.
+
+set -euo pipefail
+
+app='/Applications/Neovide.app'
+
+# Create wrapper if it doesn't exist
+cat > "${app}/Contents/MacOS/neovide-wrapper.sh" <<'EOF'
+#!/bin/zsh
+source ~/.zshrc
+exec "$(dirname "$0")/neovide" "$@"
+EOF
+
+chmod +x "${app}/Contents/MacOS/neovide-wrapper.sh"
+
+# Update plist to use wrapper
+/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable neovide-wrapper.sh" "${app}/Contents/Info.plist"
+
+# Clear code signature
+sudo xattr -cr "${app}"
+
+# Re-sign the app
+sudo codesign --force --deep --sign - "${app}"
+
+# Verify signature
+codesign -dv "${app}"
+
+/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "${app}"
+
+echo "Neovide fixed!"
+
diff --git a/bin/notify b/bin/notify
new file mode 100755
index 0000000..59ae5be
--- /dev/null
+++ b/bin/notify
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+set -euo pipefail
+
+title="$1"
+message="${2:-}"
+
+terminal-notifier \
+ -title "$title" \
+ -message "$message" \
+ -activate com.mitchellh.ghostty
+
diff --git a/bin/open b/bin/open
new file mode 100755
index 0000000..65efddb
--- /dev/null
+++ b/bin/open
@@ -0,0 +1,302 @@
+#!/bin/bash
+#
+# macOS-style `open` for Linux desktops. Linked on every host like the rest of
+# bin/, so it steps aside where it does not apply: on macOS it hands over to the
+# real /usr/bin/open, and on Linux it refuses without a graphical session.
+#
+# Everything starts detached, as it does on macOS: a new session (setsid), stdin
+# from /dev/null, stdout/stderr to the journal. So the app neither dies with the
+# terminal nor holds it open -- an ssh session that ran `open` can still exit.
+# Its output: journalctl --user -t <program>
+
+# First, before anything bash 3.2 (macOS's /bin/bash) might not parse.
+if [[ "$OSTYPE" == darwin* ]]
+then
+ exec /usr/bin/open "$@"
+fi
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: open [-a APP] [-n] [-W] [FILE|URL ...] [--args ARG ...]
+ open -t|-e [FILE ...]
+ open -R FILE ...
+
+ (none) open each FILE or URL with its default handler (xdg-open)
+ -a APP open with APP: a desktop entry id (org.kde.dolphin or just dolphin),
+ its Name= (Alacritty), a path to a .desktop file, or a command
+ -t, -e open with the default text editor
+ -R reveal in the file manager
+ -W wait for the app to exit instead of detaching (needs -a, -t or -e)
+ -n accepted for compatibility; every launch is a new process anyway
+ --args pass everything after it to the app verbatim
+EOF
+}
+
+die() {
+ >&2 echo "open: $*"
+ exit 1
+}
+
+# The value of KEY in the [Desktop Entry] group, ignoring [Desktop Action ...]
+# groups, which reuse Name= and Exec=.
+entry_key() {
+ awk -v key="$2=" '
+ /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next }
+ in_entry && index($0, key) == 1 { print substr($0, length(key) + 1); exit }
+ ' "$1"
+}
+
+# Path of the desktop entry for $1, case-insensitively: its id with or without
+# .desktop (org.kde.dolphin), the last part of a reverse-DNS id (dolphin), or
+# failing both its Name= (Alacritty). User entries shadow system ones.
+find_entry() {
+ local want="${1%.desktop}" dirs dir f id entries=()
+ want="${want,,}"
+ IFS=: read -ra dirs <<< "${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+ for dir in "${dirs[@]}"
+ do
+ for f in "$dir"/applications/*.desktop
+ do
+ [[ -f "$f" ]] || continue
+ entries+=("$f")
+ id="${f##*/}"
+ id="${id%.desktop}"
+ id="${id,,}"
+ if [[ "$id" == "$want" || "${id##*.}" == "$want" ]]
+ then
+ echo "$f"
+ return
+ fi
+ done
+ done
+ (( ${#entries[@]} )) || return 1
+ awk -v want="$want" '
+ FNR == 1 { in_entry = 0 }
+ /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next }
+ in_entry && /^Name=/ && tolower(substr($0, 6)) == want { print FILENAME; found = 1; exit }
+ END { exit !found }
+ ' "${entries[@]}"
+}
+
+# Splits an Exec= value into NUL-terminated words by the desktop entry spec:
+# blanks separate, "..." groups, and within quotes a backslash escapes the next
+# character. The spec's outer string escapes (\s, \\\\) are not undone.
+exec_words() {
+ local s="$1" word='' have='' quoted='' c i
+ for (( i = 0; i < ${#s}; i++ ))
+ do
+ c="${s:i:1}"
+ if [[ -n "$quoted" ]]
+ then
+ if [[ "$c" == '\' ]]
+ then
+ (( ++i ))
+ word+="${s:i:1}"
+ elif [[ "$c" == '"' ]]
+ then
+ quoted=''
+ else
+ word+="$c"
+ fi
+ elif [[ "$c" == '"' ]]
+ then
+ quoted=1 have=1
+ elif [[ "$c" == ' ' || "$c" == $'\t' ]]
+ then
+ if [[ -n "$have" ]]
+ then
+ printf '%s\0' "$word"
+ fi
+ word='' have=''
+ else
+ word+="$c" have=1
+ fi
+ done
+ if [[ -n "$have" ]]
+ then
+ printf '%s\0' "$word"
+ fi
+}
+
+# Fills argv from a desktop entry. The files replace its %f/%F/%u/%U field code,
+# or go last if it has none, and --args go just before them. %f and %u mean one
+# file per launch in the spec; all of them are passed to a single launch here.
+from_entry() {
+ local entry="$1" exec word t placed='' extras_placed=''
+ exec="$(entry_key "$entry" Exec)"
+ [[ -n "$exec" ]] || die "$entry has no Exec="
+ argv=()
+ if [[ "$(entry_key "$entry" Terminal)" == true ]]
+ then
+ for t in ${TERMINAL:-} alacritty konsole xterm
+ do
+ if command -v "$t" >/dev/null
+ then
+ argv=("$t" -e)
+ break
+ fi
+ done
+ (( ${#argv[@]} )) || die "$entry needs a terminal and none was found"
+ fi
+ while IFS= read -r -d '' word
+ do
+ case "$word" in
+ # flatpak's file forwarding markers: app arguments go before them, not inside
+ @@u|@@f)
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ extras_placed=1
+ argv+=("$word")
+ ;;
+ %[fFuU])
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ argv+=("${files[@]}")
+ extras_placed=1 placed=1
+ ;;
+ %%) argv+=(%) ;;
+ %?) ;;
+ *) argv+=("${word//"%%"/%}") ;;
+ esac
+ done < <(exec_words "$exec")
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ [[ -n "$placed" ]] || argv+=("${files[@]}")
+}
+
+# Fills argv with the command line that opens the files with app $1.
+resolve() {
+ local app="$1" entry cmd
+ if [[ "$app" == *.desktop && -f "$app" ]]
+ then
+ entry="$app"
+ else
+ entry="$(find_entry "$app")" || entry=''
+ fi
+ if [[ -n "$entry" ]]
+ then
+ from_entry "$entry"
+ elif cmd="$(command -v -- "$app" || command -v -- "${app,,}")"
+ then
+ argv=("$cmd" "${extra[@]}" "${files[@]}")
+ else
+ die "no application named $app"
+ fi
+ command -v -- "${argv[0]}" >/dev/null || die "$app: ${argv[0]} is not installed"
+}
+
+launch() {
+ if [[ -n "$wait" ]]
+ then
+ "$@"
+ elif command -v systemd-cat >/dev/null
+ then
+ setsid -f systemd-cat -t "${1##*/}" "$@" </dev/null >/dev/null 2>&1
+ else
+ setsid -f "$@" </dev/null >/dev/null 2>&1
+ fi
+}
+
+# Percent-encodes byte by byte, hence LC_ALL=C: ${path:i:1} must be one byte,
+# not one character, for a UTF-8 name to come out right.
+file_uri() {
+ local LC_ALL=C path out='' c i
+ path="$(realpath -- "$1")"
+ for (( i = 0; i < ${#path}; i++ ))
+ do
+ c="${path:i:1}"
+ case "$c" in
+ [A-Za-z0-9/._~-]) out+="$c" ;;
+ *) printf -v c '%%%02X' "'$c"; out+="$c" ;;
+ esac
+ done
+ echo "file://$out"
+}
+
+# Through org.freedesktop.FileManager1, which Dolphin, Nautilus, Nemo and Thunar
+# all implement; without it, open the containing directories instead.
+reveal() {
+ local f uris=()
+ (( $# )) || die "-R needs a file"
+ for f in "$@"
+ do
+ [[ -e "$f" ]] || die "the file $f does not exist"
+ uris+=("$(file_uri "$f")")
+ done
+ if dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 \
+ /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems \
+ "array:string:$(IFS=,; echo "${uris[*]}")" string:'' >/dev/null 2>&1
+ then
+ return
+ fi
+ for f in "$@"
+ do
+ launch xdg-open "$(dirname -- "$(realpath -- "$f")")"
+ done
+}
+
+app='' editor='' reveal='' wait=''
+files=() extra=() argv=()
+while (( $# ))
+do
+ case "$1" in
+ -a) (( $# >= 2 )) || die "-a needs an application"; app="$2"; shift 2 ;;
+ -t|-e) editor=1; shift ;;
+ -R) reveal=1; shift ;;
+ -W) wait=1; shift ;;
+ -n) shift ;;
+ -h|--help) usage; exit 0 ;;
+ --args) shift; extra=("$@"); break ;;
+ --) shift; files+=("$@"); break ;;
+ # Bundled short flags, as in `open -na Alacritty`.
+ -[!-]?*)
+ flags=()
+ for (( i = 1; i < ${#1}; i++ ))
+ do
+ flags+=("-${1:i:1}")
+ done
+ shift
+ set -- "${flags[@]}" "$@"
+ ;;
+ -?*) die "unsupported option $1 (see open --help)" ;;
+ *) files+=("$1"); shift ;;
+ esac
+done
+
+# A server, or ssh into a desktop without forwarding: nothing could show up.
+[[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || die "no graphical session (WAYLAND_DISPLAY and DISPLAY are unset)"
+
+for f in "${files[@]}"
+do
+ [[ -e "$f" || "$f" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] || die "the file $f does not exist"
+done
+
+if [[ -n "$reveal" ]]
+then
+ reveal "${files[@]}"
+ exit
+fi
+
+if [[ -n "$editor" ]]
+then
+ app="$(xdg-mime query default text/plain)"
+ [[ -n "$app" ]] || die "no default text editor (xdg-mime query default text/plain)"
+fi
+
+if [[ -z "$app" ]]
+then
+ [[ -z "$wait" ]] || die "-W needs -a, -t or -e: xdg-open returns before the app exits"
+ (( ${#extra[@]} == 0 )) || die "--args needs -a"
+ if (( ${#files[@]} == 0 ))
+ then
+ >&2 usage
+ exit 1
+ fi
+ for f in "${files[@]}"
+ do
+ launch xdg-open "$f"
+ done
+ exit
+fi
+
+resolve "$app"
+launch "${argv[@]}"
diff --git a/bin/pyexec b/bin/pyexec
new file mode 100755
index 0000000..303bc4b
--- /dev/null
+++ b/bin/pyexec
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -euo pipefail
+
+debug() {
+ if [[ "${DEBUG:-}" == "pyexec" ]]; then
+ >&2 echo "[DEBUG=pyexec]" "$@"
+ fi
+}
+
+if [[ -n "${VIRTUAL_ENV:-}" ]]; then
+ debug "Detected VIRTUAL_ENV"
+ i=python
+elif [[ -r ".venv/bin/activate" ]]; then
+ debug "Found .venv, activating"
+ source .venv/bin/activate
+ i=python
+elif command -v python3 >/dev/null; then
+ debug "Found python3 in path"
+ i=python3
+else
+ debug "Defaulting to python"
+ i=python
+fi
+
+exec "$i" "$@"
+
diff --git a/bin/random-id.ts b/bin/random-id.ts
new file mode 100755
index 0000000..b8bf364
--- /dev/null
+++ b/bin/random-id.ts
@@ -0,0 +1,105 @@
+#!/usr/bin/env node --disable-warning=DEP0180 --no-warnings
+
+const idChars = (function (): string[] {
+ const chars: string[] = [];
+
+ function code(x: string | number): number {
+ return typeof x === "string" ? x.charCodeAt(0) : x;
+ }
+
+ function add(from: string | number, to: string | number): void {
+ const fromCode = code(from);
+ const toCode = code(to);
+ for (let c = fromCode; c <= toCode; ++c) {
+ chars.push(String.fromCharCode(c));
+ }
+ }
+
+ add("0", "9");
+ add("a", "z");
+ add("A", "Z");
+ return chars;
+})();
+
+
+/*
+Alphabet size: 62 characters (A–Z, a–z, 0–9)
+Total possible IDs of length n: TOTAL_IDS = 62^n
+To compute the probability that two randomly generated IDs collide (i.e., are identical): P_COLLISION = 1 / TOTAL_IDS
+
+==========================================================
+ ID Length | ID space | P(Collision) | Intuition
+-----------|----------------|----------------|------------
+ 5 | 916,132,832 | ~1.09 × 10⁻⁹ | ~1 in a billion (like lottery odds)
+ 6 | 56,800,235,584 | ~1.76 × 10⁻¹¹ | ~1 in 50 billion (world population × 6)
+ 17 | 2.96 × 10³⁰ | ~3.38 × 10⁻³¹ | ~1 in a nonillion (grains of sand × 10¹⁹)
+==========================================================
+
+Interpretation:
+- At **length 5**, collisions are rare but not implausible if generating millions of IDs.
+- At **length 6**, collisions are extremely rare - safe for most casual uses.
+- At **length 17**, collisions are cosmically unlikely—about as probable as picking the same atom out of all atoms in the solar system.
+
+==========================================================
+== Out of a pool of 100,000 ===
+
+Alphabet size: 62 characters (A–Z, a–z, 0–9)
+Existing IDs: N = 100 000
+
+Formulas:
+ TOTAL_IDS(n) = 62^n
+ P_match_one = 1 / TOTAL_IDS(n)
+ P_collision_any = 1 − (1 − P_match_one)^N
+
+--------------------------------------------------------------------------------
+ n | TOTAL_IDS(n) | P_collision_any | Intuition
+---+----------------------+--------------------------------+-------------------------------------------
+ 5 | 62^5 = 916 132 832 | 1 − (1 − 1/916 132 832)^100 000
+ | | ≈ 1.09 × 10⁻⁴ | ~0.01% → ~1 in 9 200
+ | | | (about as rare as drawing the same
+ | | | lottery number in ~9 200 draws)
+
+ 6 | 62^6 = 56 800 235 584| ≈ 1.76 × 10⁻⁶ | ~0.000176% → ~1 in 568 000
+ | | | (like finding a specific LEGO
+ | | | brick in a pile of half a million)
+
+ 8 | 62^8 ≈ 2.18 × 10^14 | ≈ 4.58 × 10⁻¹⁰ | ~4.6 × 10⁻⁸% → ~1 in 2.2 billion
+ | | | (on par with winning a major lottery)
+
+17 | 62^17 ≈ 2.96 × 10^30 | ≈ 3.38 × 10⁻²⁶ | ~3.4 × 10⁻²⁴% → ~1 in 3 × 10^25
+ | | | (effectively zero—astronomically
+ | | | unlikely, far beyond grains of sand
+ | | | on every beach on Earth)
+--------------------------------------------------------------------------------
+
+Reasoning:
+- TOTAL_IDS(n) counts all possible length-n strings.
+- P_match_one = chance it matches one given ID.
+- With N existing, P_collision_any = 1 minus the chance it misses all N.
+- For n=5 collisions are possible if you generate many IDs; for n≥8 they're vanishingly rare; for n=17 they're effectively impossible in any realistic system.
+
+ */
+
+// With 62 possible chars, a 17 char long ID has about 10^30 possible values,
+// the chance of finding any of 8 billion of them is about the same as the
+// chance of finding a single specific grain of sand in on earth.
+//
+const idLength = process.env.ID_LENGTH ? parseInt(process.env.ID_LENGTH) :17;
+
+function randomId(length: number = idLength): string {
+ const chars: string[] = [];
+ for (let i = 0; i < length; ++i) {
+ const c = Math.floor(Math.random() * idChars.length);
+ chars.push(idChars[c]!);
+ }
+ return chars.join("");
+}
+
+// take count from from first argument, default to 1
+const count = parseInt(process.argv[2] || "1");
+
+// execute and print
+for (let i = 0; i < count; ++i) {
+ console.log(randomId());
+}
+
diff --git a/bin/smack b/bin/smack
new file mode 100755
index 0000000..667ab2a
--- /dev/null
+++ b/bin/smack
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+git add -A . \
+ && git commit -m 'Update'
+git push
diff --git a/bin/sz-rm-meta.sh b/bin/sz-rm-meta.sh
new file mode 100755
index 0000000..5618dc2
--- /dev/null
+++ b/bin/sz-rm-meta.sh
@@ -0,0 +1,477 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# sz-rm-meta.sh — strip metadata from image files.
+#
+# sz-rm-meta.sh [options] FILE...
+#
+# -l, --list report what would be removed; change nothing
+# -o, --out DIR write cleaned copies into DIR instead of editing in place
+# --keep-icc keep the embedded ICC colour profile (stripped by default)
+# --keep-xattr leave extended attributes alone (macOS: kMDItemWhereFroms,
+# com.apple.quarantine — these record the download URL)
+# -d, --destructive re-encode the pixels as well (see "Two modes" below)
+# --quality N JPEG quality for --destructive (default 85)
+# --scale PCT resize percentage for --destructive (default 97)
+# --rename rename the file to a random name, keeping the extension
+# -q, --quiet only report failures
+#
+# Two modes, because "no metadata" and "not traceable" are different jobs:
+#
+# default Lossless. Rewrites the container, copies the compressed image
+# data byte for byte. Pixels stay bit-identical, so the file is
+# still matchable against the source by perceptual hash, and the
+# quantization/Huffman tables still fingerprint the encoder that
+# produced it. Right for sanitising an archive you keep.
+#
+# --destructive Re-encodes: resize by --scale, re-compress at --quality, fresh
+# quantization tables, then run the lossless pass over the
+# result. Breaks bitstream-level linkage to the source copy.
+# Does NOT defeat reverse-image search or perceptual hashing —
+# nothing that preserves what the picture looks like can. Needs
+# ImageMagick.
+#
+# What the lossless pass removes, per format:
+# JPEG every APPn segment except JFIF (APP0) and Adobe (APP14), all COM
+# comments, and any trailer appended after the EOI marker. That covers
+# EXIF (camera, GPS, serial, timestamps), XMP, IPTC and the Photoshop
+# 8BIM block (by-line, credit, document ancestry).
+# PNG everything outside an allowlist of image and colour chunks. An
+# allowlist rather than a denylist because private chunks are the worst
+# offenders: Fireworks' mkBF/mkBS/mkBT embed the entire editable source
+# document, and prVw carries a full preview image.
+#
+# Why APP14 "Adobe" survives: it is not metadata, it declares the colour
+# transform (YCbCr vs RGB vs YCCK). Dropping it turns some CMYK and RGB JPEGs
+# into colour-inverted garbage. It is a 12-byte codec record written by many
+# encoders, not a Photoshop watermark.
+#
+# Symlinks are resolved and the real file is rewritten, so cleaning a link no
+# longer leaves the target dirty. A file with more than one hard link is
+# rewritten in place rather than replaced, so every name sees the clean data —
+# that write is not atomic, which is the honest trade for not silently breaking
+# the link.
+#
+# Files are otherwise rewritten via a temp file in the same directory and moved
+# into place, so an interrupted run leaves the original intact. A file that
+# fails to parse is skipped, never half-written.
+#
+# Deliberately not handled: GIF, TIFF, HEIC, WebP, video. They need a real
+# metadata library; this script would rather refuse than corrupt them. Install
+# exiftool for those.
+
+die() { >&2 echo "sz-rm-meta: $*"; exit 1; }
+warn() { >&2 echo "sz-rm-meta: warning: $*"; }
+
+list_only=false
+out_dir=""
+keep_icc=false
+keep_xattr=false
+destructive=false
+rename=false
+quality=85
+scale=97
+quiet=false
+files=()
+
+usage() {
+ cat <<'USAGE'
+sz-rm-meta.sh [options] FILE... strip metadata from JPEG/PNG
+
+ -l, --list report what would be removed; change nothing
+ -o, --out DIR write cleaned copies into DIR instead of editing in place
+ --keep-icc keep the embedded ICC colour profile (stripped by default)
+ --keep-xattr leave extended attributes alone (macOS keeps the download
+ URL in kMDItemWhereFroms)
+ -d, --destructive also re-encode the pixels: breaks the bitstream fingerprint
+ linking this copy to the source. Needs ImageMagick.
+ --quality N JPEG quality for --destructive (default 85)
+ --scale PCT resize percentage for --destructive (default 97)
+ --rename rename to a random name, keeping the extension
+ -q, --quiet only report failures
+
+Default mode is lossless: pixels stay bit-identical, which also means the file
+still matches the source under reverse-image search or perceptual hashing.
+Use --destructive when the goal is that a copy cannot be linked to its origin.
+USAGE
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -l|--list) list_only=true; shift ;;
+ -o|--out) [ "$#" -ge 2 ] || die "--out needs a directory"; out_dir="$2"; shift 2 ;;
+ --keep-icc) keep_icc=true; shift ;;
+ --keep-xattr) keep_xattr=true; shift ;;
+ -d|--destructive) destructive=true; shift ;;
+ --quality) [ "$#" -ge 2 ] || die "--quality needs a number"; quality="$2"; shift 2 ;;
+ --scale) [ "$#" -ge 2 ] || die "--scale needs a number"; scale="$2"; shift 2 ;;
+ --rename) rename=true; shift ;;
+ -q|--quiet) quiet=true; shift ;;
+ -h|--help) usage; exit 0 ;;
+ --) shift; files+=("$@"); break ;;
+ -*) die "unknown option '$1' (try --help)" ;;
+ *) files+=("$1"); shift ;;
+ esac
+done
+
+[ "${#files[@]}" -gt 0 ] || die "no files given (try --help)"
+command -v python3 >/dev/null || die "python3 not found"
+
+case "${quality}" in ''|*[!0-9]*) die "--quality must be a number" ;; esac
+case "${scale}" in ''|*[!0-9]*) die "--scale must be a number" ;; esac
+if [ "${quality}" -lt 1 ] || [ "${quality}" -gt 100 ]; then die "--quality must be 1-100"; fi
+if [ "${scale}" -lt 1 ] || [ "${scale}" -gt 100 ]; then die "--scale must be 1-100"; fi
+
+if [ -n "${out_dir}" ]; then
+ ${list_only} && die "--list and --out are mutually exclusive"
+ mkdir -p "${out_dir}"
+fi
+
+magick_cmd=""
+if ${destructive}; then
+ ${list_only} && die "--list and --destructive are mutually exclusive"
+ if command -v magick >/dev/null 2>&1; then
+ magick_cmd="magick"
+ elif command -v convert >/dev/null 2>&1; then
+ magick_cmd="convert"
+ else
+ die "--destructive needs ImageMagick (brew install imagemagick / apt install imagemagick)"
+ fi
+fi
+
+if ${rename} && ${list_only}; then
+ die "--list and --rename are mutually exclusive"
+fi
+
+# Whether this host can inspect xattrs at all. Without a tool we must say so
+# rather than report a reassuring zero: Linux browsers write the download URL to
+# user.xdg.origin.url, the direct analogue of macOS kMDItemWhereFroms.
+xattr_tool=""
+if command -v xattr >/dev/null 2>&1; then
+ xattr_tool="macos"
+elif command -v getfattr >/dev/null 2>&1 && command -v setfattr >/dev/null 2>&1; then
+ xattr_tool="linux"
+fi
+if [ -z "${xattr_tool}" ] && ! ${keep_xattr} && [ -z "${out_dir}" ]; then
+ warn "no xattr/setfattr on this host; extended attributes are NOT being checked or cleared"
+fi
+
+# Clear the extended attributes that carry provenance. com.apple.provenance is
+# system-managed and cannot be removed - it holds no information about the
+# image, so it is excluded from the count and left alone.
+clear_xattrs() {
+ local f="$1" n=0 a
+ case "${xattr_tool}" in
+ macos)
+ n=$(xattr "${f}" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true)
+ if [ "${n}" -gt 0 ]; then
+ xattr -c "${f}" 2>/dev/null || true
+ fi
+ ;;
+ linux)
+ while read -r a; do
+ [ -n "${a}" ] || continue
+ if setfattr -x "${a}" "${f}" 2>/dev/null; then
+ n=$((n + 1))
+ fi
+ done < <(getfattr --absolute-names -m '^user\.' "${f}" 2>/dev/null |
+ grep '^user\.' || true)
+ ;;
+ esac
+ echo "${n}"
+}
+
+count_xattrs() {
+ case "${xattr_tool}" in
+ macos) xattr "$1" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true ;;
+ linux) getfattr -m '^user\.' "$1" 2>/dev/null | grep -c '^user\.' || true ;;
+ *) echo 0 ;;
+ esac
+}
+
+strip_one() {
+ python3 - "$1" "$2" "$3" "$4" <<'PY'
+import os, shutil, struct, sys, tempfile
+
+src, dst, mode, keep_icc = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "true"
+listing = mode == "list"
+
+def fail(msg):
+ sys.stderr.write("sz-rm-meta: %s: %s\n" % (os.path.basename(src), msg))
+ sys.exit(2)
+
+# Follow symlinks: replacing the link node would leave the real file dirty while
+# reporting success, and would silently turn the link into a regular file.
+src = os.path.realpath(src)
+in_place = os.path.realpath(dst) == src
+if in_place:
+ dst = src
+
+d = open(src, "rb").read()
+removed = {} # label -> [count, bytes], insertion-ordered
+
+def note(label, n):
+ slot = removed.setdefault(label, [0, 0])
+ slot[0] += 1
+ slot[1] += n
+
+def summary():
+ parts = []
+ for label, (count, size) in removed.items():
+ parts.append("%s%s %dB" % (label, " x%d" % count if count > 1 else "", size))
+ return ", ".join(parts)
+
+def jpeg_label(marker, body):
+ if marker == 0xFE:
+ return "comment"
+ if body.startswith(b"Exif\x00"):
+ return "EXIF"
+ if body.startswith(b"http://ns.adobe.com/xap"):
+ return "XMP"
+ if body.startswith(b"http://ns.adobe.com/xmp/extension"):
+ return "XMP-extended"
+ if body.startswith(b"Photoshop 3.0"):
+ return "IPTC/8BIM"
+ if body.startswith(b"ICC_PROFILE"):
+ return "ICC"
+ if body.startswith(b"Ducky"):
+ return "Ducky"
+ if body.startswith(b"http://ns.google.com"):
+ return "Google-XMP"
+ if body.startswith(b"MPF\x00"):
+ return "MPF"
+ return "APP%d" % (marker - 0xE0)
+
+def strip_jpeg(d):
+ out = bytearray(d[:2])
+ i, n = 2, len(d)
+ saw_eoi = False
+ while i < n - 1:
+ if d[i] != 0xFF:
+ fail("malformed JPEG: expected a marker at offset %d" % i)
+ m = d[i + 1]
+ if m == 0xFF: # fill byte
+ out += d[i:i + 1]; i += 1; continue
+ if m == 0xD9: # EOI
+ out += d[i:i + 2]; i += 2; saw_eoi = True; break
+ if m == 0x01 or 0xD0 <= m <= 0xD7: # TEM / RSTn: no payload
+ out += d[i:i + 2]; i += 2; continue
+ if i + 4 > n:
+ fail("truncated JPEG segment header")
+ ln = struct.unpack(">H", d[i + 2:i + 4])[0]
+ if ln < 2 or i + 2 + ln > n:
+ fail("truncated JPEG segment (marker FF%02X)" % m)
+ body = d[i + 4:i + 2 + ln]
+ keep = True
+ if 0xE0 <= m <= 0xEF or m == 0xFE:
+ label = jpeg_label(m, body)
+ if m == 0xE0 and body.startswith(b"JFIF\x00"):
+ # Density header. Its last two fields are thumbnail dimensions;
+ # a non-zero pair means an embedded RGB preview of the image
+ # rides along, so drop the segment rather than keep the preview.
+ keep = not (len(body) >= 14 and body[12] and body[13])
+ if not keep:
+ note("JFIF-thumbnail", ln + 2)
+ elif m == 0xE0 and body.startswith(b"JFXX\x00"):
+ keep = False # exists only to carry a thumbnail
+ note("JFXX-thumbnail", ln + 2)
+ elif m == 0xEE and body.startswith(b"Adobe"):
+ keep = True # colour transform: a codec parameter
+ elif label == "ICC" and keep_icc:
+ keep = True
+ else:
+ keep = False
+ note(label, ln + 2)
+ if keep:
+ out += d[i:i + 2 + ln]
+ i += 2 + ln
+ if m == 0xDA: # entropy-coded scan follows
+ j = i
+ while j < n - 1:
+ if d[j] != 0xFF:
+ j += 1; continue
+ nx = d[j + 1]
+ if nx == 0x00 or 0xD0 <= nx <= 0xD7:
+ j += 2; continue # byte stuffing / restart marker
+ if nx == 0xFF:
+ j += 1; continue # fill byte
+ break
+ out += d[i:j]
+ i = j
+ if not saw_eoi:
+ fail("no EOI marker found; refusing to write a truncated file")
+ if i < n:
+ note("trailer", n - i) # appended thumbnails, depth maps, junk
+ return bytes(out)
+
+# Allowlist, not denylist: anything not named here is dropped. Covers the image
+# itself, transparency, colour rendering, and APNG animation. Private chunks
+# (mkBF/prVw/cmOD and friends) fall through to the drop path by construction.
+PNG_KEEP = {
+ b"IHDR", b"PLTE", b"IDAT", b"IEND", # critical
+ b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"sBIT", # colour / transparency
+ b"bKGD", b"pHYs", b"hIST", b"sPLT", # rendering hints
+ b"acTL", b"fcTL", b"fdAT", # APNG: dropping these
+} # would flatten the animation
+
+def strip_png(d):
+ out = bytearray(d[:8])
+ i, n = 8, len(d)
+ saw_end = False
+ while i + 8 <= n:
+ ln = struct.unpack(">I", d[i:i + 4])[0]
+ typ = d[i + 4:i + 8]
+ end = i + 12 + ln
+ if end > n:
+ fail("truncated PNG chunk '%s'" % typ.decode("ascii", "replace"))
+ keep = typ in PNG_KEEP or (typ == b"iCCP" and keep_icc)
+ if keep:
+ out += d[i:end]
+ else:
+ note(typ.decode("ascii", "replace"), ln + 12)
+ i = end
+ if typ == b"IEND":
+ saw_end = True
+ break
+ if not saw_end:
+ fail("no IEND chunk found; refusing to write a truncated file")
+ if i < n:
+ note("trailer", n - i)
+ return bytes(out)
+
+if d[:2] == b"\xff\xd8":
+ cleaned = strip_jpeg(d)
+elif d[:8] == b"\x89PNG\r\n\x1a\n":
+ cleaned = strip_png(d)
+else:
+ fail("unsupported format (only JPEG and PNG; use exiftool for others)")
+
+links = os.stat(src).st_nlink if in_place else 1
+
+if not listing and (removed or not in_place):
+ if in_place and links > 1:
+ # Replacing the inode would clean this name and leave every other hard
+ # link holding the original metadata. Rewrite the inode instead so all
+ # names see the clean data, accepting a non-atomic write.
+ with open(dst, "r+b") as fh:
+ fh.write(cleaned)
+ fh.truncate()
+ else:
+ # Write beside the destination so the rename is atomic on the same volume.
+ dst_dir = os.path.dirname(os.path.abspath(dst)) or "."
+ fd, tmp = tempfile.mkstemp(dir=dst_dir, prefix=".sz-rm-meta.")
+ try:
+ with os.fdopen(fd, "wb") as fh:
+ fh.write(cleaned)
+ shutil.copymode(src, tmp)
+ os.replace(tmp, dst)
+ except BaseException:
+ if os.path.exists(tmp):
+ os.unlink(tmp)
+ raise
+
+print("%d\t%d\t%d\t%s" % (len(d), len(cleaned), links, summary()))
+PY
+}
+
+# Re-encode into a temp file: new quantization tables, new bitstream, slightly
+# different dimensions. -strip drops what ImageMagick knows about; the lossless
+# pass afterwards catches what it leaves behind (it writes its own tEXt/date
+# chunks into PNG, for one).
+reencode() {
+ local src="$1" tmp="$2"
+ "${magick_cmd}" "${src}" -strip -resize "${scale}%" -quality "${quality}" \
+ -interlace none "${tmp}" 2>/dev/null
+}
+
+random_name() {
+ local ext="${1##*.}"
+ local stem
+ stem=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 12)
+ if [ "${ext}" = "$1" ]; then
+ echo "${stem}"
+ else
+ echo "${stem}.${ext}"
+ fi
+}
+
+status=0
+for f in "${files[@]}"; do
+ [ -f "${f}" ] || { >&2 echo "sz-rm-meta: not a file: ${f}"; status=1; continue; }
+
+ orig_size=$(python3 -c 'import os,sys; print(os.path.getsize(sys.argv[1]))' "${f}")
+
+ dst="${f}"
+ [ -n "${out_dir}" ] && dst="${out_dir%/}/$(basename "${f}")"
+
+ # Count before rewriting: the rewrite replaces the inode, which drops the
+ # xattrs on its own, so counting afterwards would always report zero. In
+ # --out mode the source is never touched and the fresh copy starts with no
+ # xattrs, so there is nothing to clear or report.
+ xa=0
+ if ! ${keep_xattr} && [ -z "${out_dir}" ]; then
+ xa=$(count_xattrs "${f}")
+ fi
+
+ src="${f}"
+ tmp_reenc=""
+ if ${destructive}; then
+ tmp_reenc="$(dirname "${dst}")/.sz-rm-meta-reenc.$$.$(basename "${f}")"
+ if ! reencode "${f}" "${tmp_reenc}"; then
+ rm -f "${tmp_reenc}"
+ >&2 echo "sz-rm-meta: ${f}: re-encode failed"
+ status=1; continue
+ fi
+ src="${tmp_reenc}"
+ fi
+
+ mode="strip"; ${list_only} && mode="list"
+ if ! result=$(strip_one "${src}" "${dst}" "${mode}" "${keep_icc}"); then
+ [ -n "${tmp_reenc}" ] && rm -f "${tmp_reenc}"
+ status=1; continue
+ fi
+ if [ -n "${tmp_reenc}" ]; then
+ rm -f "${tmp_reenc}"
+ fi
+
+ # Still needed for a file that was already clean, and for the hard-link path
+ # where the inode survives the rewrite along with its xattrs.
+ if [ "${xa}" -gt 0 ] && ! ${list_only}; then
+ clear_xattrs "${dst}" >/dev/null
+ fi
+
+ after=$(echo "${result}" | cut -f2)
+ links=$(echo "${result}" | cut -f3)
+ removed=$(echo "${result}" | cut -f4)
+
+ final="${dst}"
+ if ${rename} && ! ${list_only}; then
+ final="$(dirname "${dst}")/$(random_name "$(basename "${dst}")")"
+ mv -n "${dst}" "${final}" || { >&2 echo "sz-rm-meta: rename failed: ${dst}"; status=1; final="${dst}"; }
+ fi
+
+ ${quiet} && continue
+
+ detail="${removed}"
+ if [ "${xa}" -gt 0 ]; then
+ plural=s; [ "${xa}" -eq 1 ] && plural=
+ detail="${detail:+${detail}, }${xa} xattr${plural}"
+ fi
+ if [ -z "${detail}" ] && ! ${destructive}; then
+ echo "${f}: already clean"
+ elif ${list_only}; then
+ echo "${f}: would remove ${detail} (${orig_size} -> ${after} bytes)"
+ else
+ if ${destructive}; then
+ action="re-encoded q${quality} @${scale}%${detail:+, removed ${detail}}"
+ else
+ action="removed ${detail}"
+ fi
+ line="${f}: ${action} (${orig_size} -> ${after} bytes)"
+ [ "${links}" -gt 1 ] && line="${line} [${links} hard links, rewritten in place]"
+ [ "${final}" != "${dst}" ] && line="${line} -> $(basename "${final}")"
+ echo "${line}"
+ fi
+done
+
+exit "${status}"
diff --git a/bin/sz-wait-for b/bin/sz-wait-for
new file mode 100755
index 0000000..96e32a5
--- /dev/null
+++ b/bin/sz-wait-for
@@ -0,0 +1,308 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Single-shot waiter: block until the watched target changes, debounce edits for
+# 5 seconds, confirm the change is real, then exit 0. Meant to be re-invoked in a
+# loop, not to stream continuous events.
+#
+# Takes one argument: the file or directory to watch. A directory fires when
+# anything under it changes -- a file added, removed, or modified, at any depth.
+#
+# Behavior and design choices:
+# - resolve the target to an absolute path so the caller's cwd does not matter
+# - capture the baseline in memory at startup; only changes that happen while
+# this process is running are detected (no cross-run state is kept, by design)
+# - debounce bursts of edits (5 seconds of quiet after the last event)
+#
+# Change detection uses a fingerprint rather than a single mtime: a recursive
+# walk produces a sorted "path<TAB>mtime" listing that is hashed into one value,
+# so any add/remove/modify moves the hash. The walk is recursive to match the
+# watcher scope (fswatch is recursive on macOS via FSEvents, and we pass -r on
+# Linux), so a deep change can never wake us without also moving the fingerprint.
+#
+# On a clean exit it prints "Changed: <path>"; for a directory it also lists the
+# entries it noticed were added, removed, or modified (capped for readability).
+#
+# Known limitations (deliberate, to stay simple):
+# - mtime-only: a change that does not move an entry's mtime is not detected,
+# even though fswatch may wake us (we treat it as a spurious event and
+# re-arm). This covers metadata-only edits (chmod/chown/xattr) and two edits
+# to the same file within one wall-clock second on the seconds-only stat
+# fallback. Closing this would require content hashing, not worth the cost.
+# - symlinks are not followed: find lists the link node and stat reports the
+# link itself, so a change to a symlink's *target* does not move the
+# fingerprint. Watching the link node is the intended behavior.
+# - cost: the walk runs one stat (or date/stat -c) per entry, recomputed on
+# each wake and once per second while settling. Fine for small trees; on
+# very large/deep trees a sample may exceed the 1s settle tick.
+
+if [ "$#" -ne 1 ]; then
+ echo "Usage: $0 <file-or-directory>" >&2
+ exit 1
+fi
+
+TARGET="$1"
+
+if [ ! -e "$TARGET" ]; then
+ echo "Error: path does not exist: $TARGET" >&2
+ exit 1
+fi
+
+# Resolve to an absolute path so the watch target is stable even if the caller's
+# cwd changes. Directories resolve by cd'ing in; a file resolves via its parent
+# so a symlinked file resolves predictably.
+if [ -d "$TARGET" ]; then
+ ABS_TARGET="$(cd "$TARGET" && pwd)"
+else
+ case "$TARGET" in
+ /*) ABS_TARGET="$TARGET" ;;
+ *) ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" ;;
+ esac
+fi
+
+if [ ! -e "$ABS_TARGET" ]; then
+ echo "Error: path does not exist: $ABS_TARGET" >&2
+ exit 1
+fi
+
+# Accept only a regular file or a directory. Other node types (sockets, FIFOs,
+# devices) are rejected -- the fingerprint/watch logic is not meant for them.
+if [ ! -d "$ABS_TARGET" ] && [ ! -f "$ABS_TARGET" ]; then
+ echo "Error: not a regular file or directory: $ABS_TARGET" >&2
+ exit 1
+fi
+
+# Build the fswatch argument list once.
+# - Directory: watch it recursively (-r forces recursion on Linux/inotify; it
+# is harmless on macOS, where FSEvents is already recursive).
+# - File: watch the file and its parent directory so atomic editor saves
+# (write temp + rename over the target, which swaps the inode) are detected.
+if [ -d "$ABS_TARGET" ]; then
+ WATCH_ARGS=(-r "$ABS_TARGET")
+ # The directory listing relies on `find`. Fail loudly here rather than let a
+ # missing `find` collapse the walk to an empty listing (which would hash to a
+ # constant and make the watcher arm but never fire).
+ if ! command -v find >/dev/null 2>&1; then
+ echo "Error: find is required to watch a directory" >&2
+ exit 1
+ fi
+else
+ WATCH_ARGS=("$ABS_TARGET" "$(dirname "$ABS_TARGET")")
+fi
+
+# Hash whatever arrives on stdin to a stable hex digest.
+hash_stdin() {
+ if command -v shasum >/dev/null 2>&1; then
+ shasum | awk '{print $1}'
+ else
+ sha1sum | awk '{print $1}'
+ fi
+}
+
+# Normalize an mtime value to seconds.nanoseconds with exactly 9 fractional
+# digits so values are stable across different stat/date outputs.
+normalize_mtime() {
+ local mtime="$1"
+ if [[ "$mtime" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
+ local secs="${BASH_REMATCH[1]}"
+ local frac="${BASH_REMATCH[2]}"
+ while [ "${#frac}" -lt 9 ]; do frac="${frac}0"; done
+ frac="${frac:0:9}"
+ printf '%s.%s' "$secs" "$frac"
+ else
+ printf '%s.000000000' "$mtime"
+ fi
+}
+
+# Cross-platform mtime with sub-second precision where available.
+# Try the macOS/BSD high-precision stat first (avoids a failing `date` exec on
+# every poll on stock macOS, where BSD `date -r` only accepts epoch seconds).
+# GNU `date -r <file>` gives nanoseconds on Linux. The seconds-only GNU stat
+# fallback is the last resort; rapid same-second edits may be invisible there.
+# Prints the normalized mtime on stdout and returns 0; returns 1 on failure.
+mtime_of() {
+ local f="$1"
+ local out
+ if out=$(stat -f '%Fm' "$f" 2>/dev/null); then
+ normalize_mtime "$out"
+ return 0
+ elif out=$(date -r "$f" +%s.%N 2>/dev/null) && [[ "$out" =~ ^[0-9]+\.[0-9]{9}$ ]]; then
+ printf '%s' "$out"
+ return 0
+ elif out=$(stat -c '%Y' "$f" 2>/dev/null); then
+ normalize_mtime "$out"
+ return 0
+ fi
+ return 1
+}
+
+# Emit the target's "path<TAB>mtime" listing on stdout, one line per entry:
+# - File: a single line.
+# - Directory: the directory itself and every descendant, sorted by path for
+# determinism.
+# Entries that vanish mid-walk are skipped: their absence simply changes the
+# listing on the next sample, which is the correct signal. A directory always
+# yields at least itself, so an *empty* listing while it still exists means the
+# walk tooling failed (find/sort/stat); we fail loudly there rather than emit
+# nothing, which would hide a change behind the constant empty-string hash. A
+# truly vanished target also yields nothing, but the main loop's existence check
+# reports that with a clear message.
+listing_of() {
+ local target="$1"
+ if [ -d "$target" ]; then
+ local listing
+ # Requires `sort -z` (GNU sort and Apple's sort 2.3+ both support it). If a
+ # platform's sort lacks it, the pipeline yields no output and the empty
+ # listing is caught loudly below rather than degrading to a constant hash.
+ listing=$(find "$target" -print0 2>/dev/null \
+ | LC_ALL=C sort -z \
+ | while IFS= read -r -d '' entry; do
+ m=$(mtime_of "$entry") || continue
+ printf '%s\t%s\n' "$entry" "$m"
+ done) || true
+ if [ -z "$listing" ] && [ -d "$target" ]; then
+ echo "Error: directory walk produced no entries for $target (find/sort/stat failure?)" >&2
+ return 1
+ fi
+ printf '%s' "$listing"
+ else
+ local m
+ if m=$(mtime_of "$target"); then
+ printf '%s\t%s' "$target" "$m"
+ else
+ # Fail loudly rather than emit nothing and hide the change behind a hash.
+ echo "Error: unable to read mtime of $target" >&2
+ return 1
+ fi
+ fi
+}
+
+# Hash a target's listing into one digest -- cheap to compare, used only to
+# detect that *something* changed.
+fingerprint_of() {
+ listing_of "$1" | hash_stdin
+}
+
+# Summarize what changed between two listings (the "path<TAB>mtime" output of
+# listing_of) to stdout, as added/removed/modified entries with paths relative
+# to the watched root. A directory's own mtime bump is always a side effect of a
+# child being added or removed -- already reported via that child -- so those
+# "modified" lines are dropped as noise. Output is capped to stay readable.
+report_changes() {
+ local base="$1" final="$2" root="$3"
+ local max=10 shown=0 total=0 status path rel
+ while IFS=$'\t' read -r status path; do
+ # Drop pure directory mtime bumps; the child add/remove is reported too.
+ if [ "$status" = M ] && [ -d "$path" ]; then
+ continue
+ fi
+ total=$((total + 1))
+ if [ "$shown" -lt "$max" ]; then
+ case "$path" in
+ "$root") rel="." ;;
+ "$root"/*) rel="${path#"$root"/}" ;;
+ *) rel="$path" ;;
+ esac
+ case "$status" in
+ A) printf ' added: %s\n' "$rel" ;;
+ R) printf ' removed: %s\n' "$rel" ;;
+ M) printf ' modified: %s\n' "$rel" ;;
+ esac
+ shown=$((shown + 1))
+ fi
+ done < <(
+ awk -F'\t' '
+ NR==FNR { bm[$1]=$2; b[$1]=1; next }
+ { f[$1]=1
+ if (!($1 in b)) print "A\t" $1
+ else if (bm[$1] != $2) print "M\t" $1 }
+ END { for (p in b) if (!(p in f)) print "R\t" p }
+ ' <(printf '%s\n' "$base") <(printf '%s\n' "$final") | LC_ALL=C sort
+ )
+ if [ "$total" -gt "$shown" ]; then
+ printf ' ... and %d more\n' "$((total - shown))"
+ fi
+}
+
+SETTLE=5
+
+if ! command -v fswatch >/dev/null 2>&1; then
+ echo "Error: fswatch is required (https://github.com/emcrisostomo/fswatch)" >&2
+ exit 1
+fi
+
+# Capture the baseline in memory at startup. No state is kept across runs: only
+# changes that happen while this process is running are detected. Keep the raw
+# listing too so a directory change can be described, not just detected.
+BASE_LISTING="$(listing_of "$ABS_TARGET")"
+BASE_FP="$(printf '%s' "$BASE_LISTING" | hash_stdin)"
+
+while :; do
+ CURRENT_FP="$(fingerprint_of "$ABS_TARGET")"
+
+ if [ "$CURRENT_FP" = "$BASE_FP" ]; then
+ # No change yet; block on a filesystem notification.
+ # NOTE: there is a tiny check-then-arm window between the fingerprint read
+ # above and fswatch starting; a change in that gap may not be noticed until
+ # the next event wakes us.
+ # NOTE: watching a directory (or a file's parent) means unrelated entries can
+ # wake us up; the immediate fingerprint check below keeps that cheap.
+ if FS_OUT=$(fswatch -1 "${WATCH_ARGS[@]}" 2>&1); then
+ FS_RC=0
+ else
+ FS_RC=$?
+ fi
+ if [ "$FS_RC" -ne 0 ]; then
+ if [ ! -e "$ABS_TARGET" ]; then
+ echo "Error: watched path disappeared: $ABS_TARGET" >&2
+ else
+ echo "Error: fswatch failed (exit $FS_RC): $FS_OUT" >&2
+ fi
+ exit "$FS_RC"
+ fi
+ if [ ! -e "$ABS_TARGET" ]; then
+ echo "Error: watched path disappeared: $ABS_TARGET" >&2
+ exit 1
+ fi
+ fi
+
+ # Unrelated events can wake us up. Recompute the fingerprint immediately; if it
+ # has not moved, re-arm without paying the full stabilization penalty.
+ EVENT_FP="$(fingerprint_of "$ABS_TARGET")"
+ if [ "$EVENT_FP" = "$BASE_FP" ]; then
+ continue
+ fi
+
+ # Stabilize: wait until the target has been quiet for SETTLE seconds. If it
+ # changes again during that window, reset the timer. For a directory this
+ # naturally absorbs bursts like a multi-file save or a checkout.
+ LAST_FP="$EVENT_FP"
+ QUIET=0
+ while [ "$QUIET" -lt "$SETTLE" ]; do
+ sleep 1
+ if [ ! -e "$ABS_TARGET" ]; then
+ echo "Error: watched path disappeared: $ABS_TARGET" >&2
+ exit 1
+ fi
+ NOW_FP="$(fingerprint_of "$ABS_TARGET")"
+ if [ "$NOW_FP" != "$LAST_FP" ]; then
+ LAST_FP="$NOW_FP"
+ QUIET=0
+ else
+ QUIET=$((QUIET + 1))
+ fi
+ done
+
+ # Confirm the listing actually changed from the baseline. fswatch can report
+ # spurious or duplicate events, so we only fire on a real delta.
+ FINAL_LISTING="$(listing_of "$ABS_TARGET")"
+ FINAL_FP="$(printf '%s' "$FINAL_LISTING" | hash_stdin)"
+ if [ "$FINAL_FP" != "$BASE_FP" ]; then
+ echo "Changed: $ABS_TARGET" >&2
+ if [ -d "$ABS_TARGET" ]; then
+ report_changes "$BASE_LISTING" "$FINAL_LISTING" "$ABS_TARGET" >&2
+ fi
+ exit 0
+ fi
+
+done
diff --git a/bin/terminal-set-opacity b/bin/terminal-set-opacity
new file mode 100755
index 0000000..2fce324
--- /dev/null
+++ b/bin/terminal-set-opacity
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+set -euo pipefail
+
+increment="$1"
+
+_sed_i() {
+ if [[ "$(uname)" == "Darwin" ]]; then
+ sed -i '' "$@"
+ else
+ sed -i'' "$@"
+ fi
+}
+
+# Update Alacritty
+alacritty_config="$HOME/.config/alacritty/alacritty.toml"
+current="$(grep opacity "$alacritty_config" | awk '{ print $3 }' || echo 1)"
+changed="$(perl -MList::Util=min,max -e "print max(0, min(1, $current + $increment))")"
+_sed_i 's/^opacity = .*$/opacity = '"$changed"'/' "$alacritty_config"
+
+# Update Ghostty
+ghostty_config="$HOME/.config/ghostty/config"
+if [[ -f "$ghostty_config" ]]; then
+ _sed_i 's/^background-opacity = .*$/background-opacity = '"$changed"'/' "$ghostty_config"
+
+ # Trigger Ghostty reload
+ script_dir="$(cd "$(dirname "$0")" && pwd)"
+ "$script_dir/ghostty-reload"
+fi
+
+echo "$changed"
+
diff --git a/bin/theme b/bin/theme
new file mode 100755
index 0000000..39f25d1
--- /dev/null
+++ b/bin/theme
@@ -0,0 +1,554 @@
+#!/bin/bash
+
+set -euo pipefail
+
+: ${HOME:="/Users/$(whoami)"}
+: ${XDG_CONFIG_HOME:=$HOME/.config}
+: ${XDG_DATA_HOME:=$HOME/.local/share}
+
+
+_sed_i() {
+ if [[ "$(uname)" == "Darwin" ]]; then
+ sed -i '' "$@"
+ else
+ sed -i'' "$@"
+ fi
+}
+
+is_auto_dark_mode() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ defaults read -g AppleInterfaceStyleSwitchesAutomatically >/dev/null 2>&1
+ else
+ true
+ fi
+}
+is_ubuntu_desktop() {
+ [[ "$(lsb_release -is)" == "Ubuntu" ]] \
+ && dpkg -l | grep -q desktop
+}
+is_dark_mode() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ defaults read -g AppleInterfaceStyle >/dev/null 2>&1
+ elif [[ -n "${SSH_CONNECTION:-}" ]]; then
+ true
+ elif is_ubuntu_desktop ; then
+ [[ "$(gsettings get org.gnome.desktop.interface color-scheme 2>&1)" != *'light'* ]]
+ else
+ true
+ fi
+}
+set_macos_dark_mode_to() {
+ osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to '"$1"
+}
+set_dark_mode() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ set_macos_dark_mode_to "true"
+ elif is_ubuntu_desktop ; then
+ gsettings set org.gnome.desktop.interface color-scheme prefer-dark > /dev/null || true
+ fi
+}
+unset_dark_mode() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ set_macos_dark_mode_to "false"
+ elif is_ubuntu_desktop ; then
+ gsettings set org.gnome.desktop.interface color-scheme prefer-light > /dev/null || true
+ fi
+}
+get_mode() {
+ local mode="${1:-}"
+ if [[ -z "$mode" ]]; then
+ if is_dark_mode ; then
+ mode="dark"
+ else
+ mode="light"
+ fi
+ fi
+ if [[ "$mode" != "dark" && "$mode" != "light" ]]
+ then
+ >&2 echo "ERROR: unsupported mode '$mode'"
+ exit 1
+ fi
+ if [[ "${mode}" == 'dark' ]] ; then
+ echo "dark"
+ else
+ echo "light"
+ fi
+}
+set_mode() {
+ local mode="${1:-}"
+ if [[ "$(get_mode "$mode")" == 'dark' ]]
+ then
+ set_dark_mode
+ else
+ unset_dark_mode
+ fi
+}
+
+
+
+# List the available color config options
+list_colors() {
+ local ext="$1"
+ local dir="$2"
+ if [ ! -e "$dir" ] ; then
+ echo "(none)"
+ return
+ fi
+ find "${dir}/" -mindepth 1 -type f -print0 \
+ | xargs -r -0 -n 1 basename \
+ | sed 's/'"$ext"'$//' \
+ | sort
+}
+# Select the first readable file from a list of filepaths
+# specified as arguments.
+first_file_in() {
+ cd "$1"
+ shift
+ while [[ $# -ne 0 ]]
+ do
+ if [[ -r "$1" ]]
+ then
+ echo "$(pwd)/$1"
+ return 0
+ else
+ shift
+ fi
+ done
+ return 1
+}
+color_file() {
+ local dir="$1"
+ local ext="$2"
+ local colors="$3"
+ local mode="$4"
+ if ! first_file_in "$dir" \
+ "${colors}-${mode}${ext}" \
+ "${colors}${mode}${ext}" \
+ "${colors}${ext}" \
+ "${mode}${ext}"
+ then
+ >&2 echo "ERROR: unable to load $ext colors for $colors $mode from $dir"
+ return 1
+ fi
+}
+# Create or replace colors in a given config file from another
+# file specifying the color config content.
+set_colors_in_config() {
+ config_file="$1"
+ colors_file="$2"
+ line_comment_prefix="${3:-## }"
+ if [[ -L "$config_file" ]]
+ then
+ config_file="$(readlink "$config_file")"
+ fi
+ mkdir -p "$(dirname "$config_file")"
+ if [[ -r "$config_file" ]]
+ then
+ _sed_i '/GEN COLORS START/,/GEN COLORS END/d' "$config_file"
+ fi
+ cat >> "$config_file" <<EOF
+$line_comment_prefix GEN COLORS START
+$line_comment_prefix from: $colors_file
+$(cat "$colors_file")
+$line_comment_prefix GEN COLORS END
+EOF
+}
+
+alacritty_ext='.toml'
+alacritty_cfg="$XDG_DATA_HOME/alacritty/color${alacritty_ext}"
+alacritty_colors_dir1="$XDG_CONFIG_HOME/alacritty/colors"
+alacritty_colors_dir2="$HOME/src/alacritty/alacritty-theme/themes"
+alacritty_colors() {
+ {
+ list_colors "${alacritty_ext}" "$alacritty_colors_dir1"
+ list_colors "${alacritty_ext}" "$alacritty_colors_dir2"
+ } | sort
+}
+alacritty_color_file() {
+ color_file "${alacritty_colors_dir1}" "${alacritty_ext}" "$1" "$2" 2>/dev/null || \
+ color_file "${alacritty_colors_dir2}" "${alacritty_ext}" "$1" "$2" 2>/dev/null || \
+ color_file "${alacritty_colors_dir1}" "${alacritty_ext}" "$1" "$2"
+}
+alacritty_color_check_exists() {
+ alacritty_color_file "$1" "$2" > /dev/null
+}
+alacritty_colors_set() {
+ set_colors_in_config "${alacritty_cfg}" "$(alacritty_color_file "$1" "$2")"
+ touch "$XDG_CONFIG_HOME/alacritty/alacritty${alacritty_ext}"
+}
+
+bat_ext='.sh'
+bat_cfg="$XDG_DATA_HOME/bat/color.sh"
+bat_colors_dir="$XDG_CONFIG_HOME/bat/colors"
+bat_colors() {
+ # list_colors "${bat_ext}" "$bat_colors_dir"
+ bat --list-themes | cat
+}
+bat_color_file() {
+ color_file "${bat_colors_dir}" "${bat_ext}" "$1" "$2"
+}
+bat_color_check_exists() {
+ bat_color_file "$1" "$2" > /dev/null
+}
+bat_colors_set() {
+ local mode="$2"
+
+ # best for light: Coldark-Cold, Github, Monokai Extended Light, OneHalfLight, gruvbox-light
+ # best for dark: 1337, Coldark-Dark, Monokai Extended, OneHalfDark, gruvbox-dark
+ # works well with both: ansi
+ local theme="1337"
+ if [[ "$mode" == "light" ]]
+ then
+ theme="gruvbox-light"
+ fi
+
+ [[ -d "$(dirname "${bat_cfg}")" ]] || mkdir -p "$(dirname "${bat_cfg}")"
+ echo "--theme='$theme'" > "${bat_cfg}"
+}
+
+tmux_ext='.conf'
+tmux_cfg="$XDG_DATA_HOME/tmux/color.conf"
+tmux_colors_dir="$XDG_CONFIG_HOME/tmux/colors"
+tmux_colors() {
+ list_colors "${tmux_ext}" "$tmux_colors_dir"
+}
+tmux_color_file() {
+ color_file "${tmux_colors_dir}" "${tmux_ext}" "$1" "$2"
+}
+tmux_color_check_exists() {
+ tmux_color_file "$1" "$2" > /dev/null
+}
+tmux_colors_set() {
+ set_colors_in_config "${tmux_cfg}" "$(tmux_color_file "$1" "$2")"
+ tmux source-file "${tmux_cfg}"
+}
+
+neovim_ext='.lua'
+neovim_cfg="${XDG_DATA_HOME}/nvim/colors.lua"
+neovim_colors_dir="$XDG_CONFIG_HOME/nvim/colors"
+neovim_colors() {
+ list_colors "${neovim_ext}" "$neovim_colors_dir"
+}
+neovim_color_file() {
+ color_file "${neovim_colors_dir}" "${neovim_ext}" "$1" "$2"
+}
+neovim_color_check_exists() {
+ neovim_color_file "$1" "$2" 2>/dev/null > /dev/null
+}
+neovim_colors_set() {
+ colors="$1"
+ mode="$2"
+ if neovim_color_check_exists "${colors}" "${mode}"
+ then
+ set_colors_in_config "${neovim_cfg}" "$(neovim_color_file "$1" "$2")" '-- '
+ else
+ cat <<EOF > "${neovim_cfg}"
+-- GEN COLORS START
+local theme_mode = "$mode"
+local theme_colors = "$colors"
+$(cat "${neovim_colors_dir}/default.lua")
+-- GEN COLORS END
+EOF
+ fi
+}
+
+ghostty_cfg="$XDG_DATA_HOME/ghostty/color"
+ghostty_colors_dir="$XDG_CONFIG_HOME/ghostty/colors"
+# Check if a custom Ghostty color file exists
+ghostty_custom_color_file() {
+ local colors="$1"
+ local mode="$2"
+
+ # Try to find custom color file (similar to other tools)
+ if ! first_file_in "$ghostty_colors_dir" \
+ "${colors}-${mode}" \
+ "${colors}${mode}" \
+ "${colors}"
+ then
+ return 1
+ fi
+}
+# Map colorscheme names to Ghostty built-in theme names
+ghostty_theme_name() {
+ local colors="$1"
+ local mode="$2"
+
+ # Direct mappings for common colorschemes
+ case "$colors" in
+ molokai)
+ # echo "Monokai Soda"
+ # echo "Monokai Pro Spectrum"
+ echo "Brogrammer"
+ ;;
+ gruvbox)
+ if [[ "$mode" == "dark" ]]; then
+ echo "Gruvbox Dark"
+ else
+ echo "Gruvbox Light"
+ fi
+ ;;
+ papercolor)
+ if [[ "$mode" == "dark" ]]; then
+ echo "GitHub Dark Default"
+ else
+ echo "GitHub Light Default"
+ fi
+ ;;
+ solarized)
+ if [[ "$mode" == "dark" ]]; then
+ echo "Solarized Dark Patched"
+ else
+ echo "Builtin Solarized Light"
+ fi
+ ;;
+ nord)
+ echo "Nord"
+ ;;
+ dracula)
+ echo "Dracula"
+ ;;
+ *)
+ # Fallback: try to find a matching theme name (case-insensitive)
+ local search_term="$colors"
+ ghostty +list-themes --plain 2>/dev/null | sed 's/ (resources)//' | grep -i "^${search_term}$" | head -n 1 || echo ""
+ ;;
+ esac
+}
+ghostty_colors() {
+ {
+ # List custom color files first
+ if [[ -d "$ghostty_colors_dir" ]]; then
+ find "$ghostty_colors_dir" -mindepth 1 -type f -print0 \
+ | xargs -r -0 -n 1 basename \
+ | sort
+ fi
+ # Then list built-in themes
+ ghostty +list-themes --plain 2>/dev/null | sed 's/ (resources)//'
+ } | sort -u || echo "(ghostty not found)"
+}
+ghostty_color_check_exists() {
+ local colors="$1"
+ local mode="$2"
+
+ # # First check for custom color file
+ # if ghostty_custom_color_file "$colors" "$mode" >/dev/null 2>&1; then
+ # return 0
+ # fi
+
+ # Then check for built-in theme
+ local theme_name
+ theme_name="$(ghostty_theme_name "$colors" "$mode")"
+ if [[ -n "$theme_name" ]]; then
+ return 0
+ else
+ return 1
+ fi
+}
+ghostty_colors_set() {
+ local colors="$1"
+ local mode="$2"
+ local custom_file
+ local theme_name
+
+ mkdir -p "$(dirname "$ghostty_cfg")"
+
+ # First priority: use custom color file if it exists
+ if custom_file="$(ghostty_custom_color_file "$colors" "$mode" 2>/dev/null)"; then
+ cat > "$ghostty_cfg" <<EOF
+# Generated by theme script
+# Colorscheme: $colors ($mode)
+# Using custom color file: $custom_file
+$(cat "$custom_file")
+EOF
+ else
+ # Second priority: use built-in theme
+ theme_name="$(ghostty_theme_name "$colors" "$mode")"
+
+ if [[ -z "$theme_name" ]]; then
+ >&2 echo "WARNING: No Ghostty theme found for '$colors' in $mode mode, skipping Ghostty theme update"
+ return 0
+ fi
+
+ cat > "$ghostty_cfg" <<EOF
+# Generated by theme script
+# Colorscheme: $colors ($mode)
+# Using built-in theme: $theme_name
+theme = $theme_name
+EOF
+ fi
+
+ # Trigger Ghostty reload
+ local script_dir
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ "$script_dir/ghostty-reload"
+}
+
+
+
+# default colors file saves the local colors preference for each mode
+default_colors_file_dark="${XDG_DATA_HOME}/dark-colors"
+default_colors_file_light="${XDG_DATA_HOME}/light-colors"
+get_default_colors_file() {
+ local mode="${1-:}"
+ if [[ "$(get_mode "$mode")" == "dark" ]] ; then
+ echo "${default_colors_file_dark}"
+ else
+ echo "${default_colors_file_light}"
+ fi
+}
+get_colors() {
+ local mode="${1:-}"
+ local file
+ file="$(get_default_colors_file "$mode" )"
+ if [[ -r "$file" ]]
+ then
+ cat "$file"
+ else
+ is_dark_mode && echo "molokai" || echo "papercolor"
+ fi
+}
+common_colors() {
+ local col com aux types
+ col="$(mktemp)"
+ com="$(mktemp)"
+ aux="$(mktemp)"
+ types=(
+ bat
+ tmux
+ neovim
+ alacritty
+ ghostty
+ )
+ for typ in "${types[@]}"; do
+ echo "[[ $typ ]]"
+ "${typ}_colors" | sort | tee "${col}"
+ if [[ "$(wc -l < "${com}")" -gt 0 ]]; then
+ comm -12 "${com}" "${col}" > "${aux}"
+ cp "${aux}" "${com}"
+ else
+ cp "${col}" "${com}"
+ fi
+ echo ""
+ done
+ echo "[[ common ]]"
+ cat "${com}"
+ rm "${com}" "${aux}"
+}
+color_check_exists() {
+ local colors="$1"
+ local mode="$2"
+ if ! tmux_color_check_exists "$colors" "$mode"; then
+ >&2 echo "ERROR: tmux colors '$colors' not found for mode '$mode'"
+ return 1
+ fi
+ if ! alacritty_color_check_exists "$colors" "$mode"; then
+ >&2 echo "ERROR: alacritty colors '$colors' not found for mode '$mode'"
+ return 1
+ fi
+ # neovim and bat have fallback mechanisms, so don't require strict checking
+ return 0
+}
+set_colors() {
+ local mode="$1"
+ local colors="$2"
+ if [[ -z "$mode" ]]; then
+ mode="$(get_mode)"
+ fi
+ if [[ -z "$colors" ]]; then
+ colors="$(get_colors "$mode")"
+ fi
+ color_check_exists "$colors" "$mode"
+
+ bat_colors_set "$colors" "$mode"
+ tmux_colors_set "$colors" "$mode"
+ neovim_colors_set "$colors" "$mode"
+ alacritty_colors_set "$colors" "$mode"
+ ghostty_colors_set "$colors" "$mode"
+
+ local colors_file
+ colors_file="$(get_default_colors_file "$mode")"
+ mkdir -p "$(dirname "$colors_file")"
+ echo "$colors" > "$colors_file" 2>/dev/null || true
+}
+
+set_theme() {
+ local mode=$1
+ local colors=$2
+
+ set_mode "$mode"
+ set_colors "$mode" "$colors"
+
+ echo "Set theme to $(get_mode "$mode") $(get_colors "$mode")"
+}
+
+print_help() {
+ local s_name
+ s_name="$(basename $0)"
+ cat <<EOF
+Help
+
+Current set theme:
+
+ $s_name
+
+Current mode
+
+ $s_name --mode
+
+Current colorscheme
+
+ $s_name --colors
+
+Set theme
+
+ $s_name --set [light|dark] [colorscheme]
+
+List available colorschemes
+
+ $s_name --list
+ $s_name --list-bat
+ $s_name --list-tmux
+ $s_name --list-neovim
+ $s_name --list-alacritty
+ $s_name --list-ghostty
+EOF
+}
+
+if test $# -eq 0
+then
+ echo "$(get_mode) $(get_colors)"
+ exit 0
+fi
+
+while test $# -gt 0
+do
+ case $1 in
+ --mode)
+ get_mode; exit 0; ;;
+ --colors)
+ get_colors; exit 0; ;;
+ --set)
+ set_theme "${2:-}" "${3:-}"; exit 0; ;;
+ --list-alacritty)
+ alacritty_colors; exit 0; ;;
+ --list-tmux)
+ tmux_colors; exit 0; ;;
+ --list-neovim)
+ neovim_colors; exit 0; ;;
+ --list-bat)
+ bat_colors; exit 0; ;;
+ --list-ghostty)
+ ghostty_colors; exit 0; ;;
+ --list)
+ common_colors; exit 0; ;;
+ --help|-h)
+ print_help; exit 0; ;;
+ *)
+ print_help; exit 1; ;;
+ esac
+ shift
+done
+
+>&2 echo "ERROR: Unexpected state!"
+exit 1
+
diff --git a/bin/tmux-colors.zsh b/bin/tmux-colors.zsh
new file mode 100755
index 0000000..b4ba152
--- /dev/null
+++ b/bin/tmux-colors.zsh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+
+for i in {0..255}
+do
+ printf "\x1b[38;5;${i}mcolor%-5i\x1b[0m" $i
+ # if ! (( ($i + 1 ) % 8 ))
+ # then
+ # echo
+ # fi
+done
+
diff --git a/bin/truecolour-test b/bin/truecolour-test
new file mode 100755
index 0000000..11b307a
--- /dev/null
+++ b/bin/truecolour-test
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+# Based on: https://gist.github.com/XVilka/8346728
+# From: https://unix.stackexchange.com/questions/404414/print-truecolor-24-bit-test-pattern
+# You can also call it as:
+# width=1000 truecolor-test
+# for a wider pattern.
+
+awk -v term_cols="${width:-$(tput cols || echo 80)}" 'BEGIN{
+ s="/\\";
+ for (colnum = 0; colnum<term_cols; colnum++) {
+ r = 255-(colnum*255/term_cols);
+ g = (colnum*510/term_cols);
+ b = (colnum*255/term_cols);
+ if (g>255) g = 510-g;
+ printf "\033[48;2;%d;%d;%dm", r,g,b;
+ printf "\033[38;2;%d;%d;%dm", 255-r,255-g,255-b;
+ printf "%s\033[0m", substr(s,colnum%2+1,1);
+ }
+ printf "\n";
+}'
diff --git a/bin/vi b/bin/vi
new file mode 100755
index 0000000..70a08cd
--- /dev/null
+++ b/bin/vi
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# remove self from path
+PATH_save="$PATH"
+PATH=$( \
+ echo "$PATH" \
+ | tr ':' '\n' \
+ | grep -vE "$(sed 's/\//\\\//g' <<<"$HOME")"'\/\.?bin' \
+ | paste -sd ':' - \
+)
+export PATH
+
+path-of() {
+ command -v "$1"
+}
+
+editor() {
+ path-of "nvim" \
+ || path-of "vim" \
+ || path-of "vi"
+}
+
+export PATH="$PATH_save"
+exec "$(editor)" "$@"
+
diff --git a/bin/wifi b/bin/wifi
new file mode 100755
index 0000000..141210a
--- /dev/null
+++ b/bin/wifi
@@ -0,0 +1,33 @@
+#!/bin/bash -l
+
+set -euo pipefail
+
+is_on() {
+ networksetup -getairportpower en0 \
+ | grep -q ' On'
+}
+
+current() {
+ /System/Library/PrivateFrameworks/Apple80211.framework/Resources/airport -I \
+ | awk -F' SSID: ' '/ SSID: / {print $2}'
+}
+
+action() {
+ if [[ "$1" == "on" ]] && is_on
+ then
+ notify "WiFi status" "Connected to: $(current)"
+ else
+ networksetup -setairportpower en0 "$1"
+ notify "WiFi status" "On, searching for networks..."
+ fi
+}
+
+arg="${1:-''}"
+if [[ "$arg" == "on" ]]; then
+ action on
+elif [[ "$arg" == "off" ]]; then
+ action off
+else
+ >&2 echo "ERROR. Need \"on\" or \"off\" arg."
+fi
+
diff --git a/bin/zsh-colors.zsh b/bin/zsh-colors.zsh
new file mode 100755
index 0000000..e1e9596
--- /dev/null
+++ b/bin/zsh-colors.zsh
@@ -0,0 +1,39 @@
+#! /bin/zsh
+# A script to make using 256 colors in zsh less painful.
+# P.C. Shyamshankar <sykora@lucentbeing.com>
+# Copied from https://github.com/sykora/etc/blob/master/zsh/functions/spectrum/
+
+typeset -AHg FX FG BG
+
+FX=(
+ reset "%{%}"
+ bold "%{%}" no-bold "%{%}"
+ italic "%{%}" no-italic "%{%}"
+ underline "%{%}" no-underline "%{%}"
+ blink "%{%}" no-blink "%{%}"
+ reverse "%{%}" no-reverse "%{%}"
+)
+
+for color in {000..255}; do
+ FG[$color]="%{[38;5;${color}m%}"
+ BG[$color]="%{[48;5;${color}m%}"
+done
+
+
+ZSH_SPECTRUM_TEXT=${ZSH_SPECTRUM_TEXT:-Arma virumque cano Troiae qui primus ab oris}
+
+# Show all 256 colors with color number
+function spectrum_ls() {
+ for code in {000..255}; do
+ print -P -- "$code: %{$FG[$code]%}$ZSH_SPECTRUM_TEXT%{$reset_color%}"
+ done
+}
+
+# Show all 256 colors where the background is set to specific color
+function spectrum_bls() {
+ for code in {000..255}; do
+ print -P -- "$code: %{$BG[$code]%}$ZSH_SPECTRUM_TEXT%{$reset_color%}"
+ done
+}
+
+spectrum_ls