diff options
| author | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
| commit | 13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch) | |
| tree | 684690c61b7b59758c2aac132c65b81c86de0608 /bin | |
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and
szecret, an age-based secret manager whose store lives elsewhere.
History restarts here. What came before was not written to be read by
anyone else, and carried machine names, addresses and an encrypted secret
store; it is kept privately instead. What is left is meant to be safe on
any machine, including one whose owner is not me: see CLAUDE.md for the
rule it is written to.
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/alacritty-mosh | 61 | ||||
| -rw-r--r-- | bin/bash-string-expansion.sh | 12 | ||||
| -rwxr-xr-x | bin/default-browser | bin | 0 -> 168112 bytes | |||
| -rwxr-xr-x | bin/dotfiles-sync | 34 | ||||
| -rwxr-xr-x | bin/ghostty-reload | 6 | ||||
| -rwxr-xr-x | bin/mosh-tmux.sh | 17 | ||||
| -rwxr-xr-x | bin/neovide-fix.sh | 36 | ||||
| -rwxr-xr-x | bin/notify | 12 | ||||
| -rwxr-xr-x | bin/open | 302 | ||||
| -rwxr-xr-x | bin/pyexec | 27 | ||||
| -rwxr-xr-x | bin/random-id.ts | 105 | ||||
| -rwxr-xr-x | bin/smack | 5 | ||||
| -rwxr-xr-x | bin/sz-rm-meta.sh | 477 | ||||
| -rwxr-xr-x | bin/sz-wait-for | 308 | ||||
| -rwxr-xr-x | bin/terminal-set-opacity | 32 | ||||
| -rwxr-xr-x | bin/theme | 554 | ||||
| -rwxr-xr-x | bin/tmux-colors.zsh | 12 | ||||
| -rwxr-xr-x | bin/truecolour-test | 21 | ||||
| -rwxr-xr-x | bin/vi | 27 | ||||
| -rwxr-xr-x | bin/wifi | 33 | ||||
| -rwxr-xr-x | bin/zsh-colors.zsh | 39 |
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 Binary files differnew file mode 100755 index 0000000..f5a5d4c --- /dev/null +++ b/bin/default-browser 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"; +}' @@ -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 "%{[00m%}" + bold "%{[01m%}" no-bold "%{[22m%}" + italic "%{[03m%}" no-italic "%{[23m%}" + underline "%{[04m%}" no-underline "%{[24m%}" + blink "%{[05m%}" no-blink "%{[25m%}" + reverse "%{[07m%}" no-reverse "%{[27m%}" +) + +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 |
