summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-07 23:46:42 +0200
committerPaul Buetow <paul@buetow.org>2026-02-07 23:46:42 +0200
commitf408ae894e4e10bbf4358c7c07bb9618ef1353e4 (patch)
tree0722f09473a58f1b9027e271cd6bd0e96e7a390d /scripts
parentbf56ef64278a90acab9116311e649e762d39cd34 (diff)
rename
Diffstat (limited to 'scripts')
-rw-r--r--scripts/README.md3
-rwxr-xr-xscripts/ai7
-rw-r--r--scripts/brokenlinkfinder73
-rwxr-xr-xscripts/gvim7
-rwxr-xr-xscripts/hx.aichat-prompt9
-rwxr-xr-xscripts/hx.chatgpt-prompt3
-rwxr-xr-xscripts/hx.goformatter3
-rwxr-xr-xscripts/hx.hexai-prompt9
-rwxr-xr-xscripts/hx.nvim-copilot-prompt32
-rwxr-xr-xscripts/hx.prompt14
-rw-r--r--scripts/randomnote.rb30
-rw-r--r--scripts/taskwarriorfeeder.rb220
-rwxr-xr-xscripts/tmux-edit-send246
-rwxr-xr-xscripts/wol-f3s86
14 files changed, 742 insertions, 0 deletions
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..ecbc8ec
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,3 @@
+# Scripts installed to my ~/scripts
+
+Mostly quick-n-dirty ones!
diff --git a/scripts/ai b/scripts/ai
new file mode 100755
index 0000000..abcf490
--- /dev/null
+++ b/scripts/ai
@@ -0,0 +1,7 @@
+#!/usr/bin/env zsh
+
+if [ $(uname) = Darwin ]; then
+ exec hx.nvim-copilot-prompt "$@"
+else
+ exec hx.hexai-prompt "$@"
+fi
diff --git a/scripts/brokenlinkfinder b/scripts/brokenlinkfinder
new file mode 100644
index 0000000..7fe1576
--- /dev/null
+++ b/scripts/brokenlinkfinder
@@ -0,0 +1,73 @@
+#!/usr/bin/env ruby
+
+require 'net/http'
+require 'uri'
+require 'nokogiri'
+require 'set'
+
+# Method to fetch and parse HTML from a URL
+def fetch_html(url)
+ response = Net::HTTP.get_response(URI(url))
+ response.body if response.is_a?(Net::HTTPSuccess)
+rescue StandardError => e
+ puts "Error fetching #{url}: #{e.message}"
+ nil
+end
+
+# Method to find and check links on a page
+def check_links(url, domain)
+ html = fetch_html(url)
+ return unless html
+
+ checked = Set.new
+ broken = Set.new
+
+ document = Nokogiri::HTML(html)
+ links = document.css('a').map { |link| link['href'] }.compact
+
+ internal_links = links.select do |link|
+ link.start_with?('/') || link.start_with?('./') || URI(link).host == domain
+ end
+ puts "Internal links: #{internal_links}"
+
+ internal_links.uniq.each do |link|
+ full_url = link.start_with?('/') || link.start_with?('./') ? "#{url}#{link}" : link
+ full_url.sub!('./', '/')
+ next if checked.include?(full_url)
+
+ broken << full_url unless check_link(full_url)
+ checked << full_url
+ end
+
+ broken
+end
+
+# Method to check if a link is broken
+def check_link(url)
+ uri = URI(url)
+ response = Net::HTTP.get_response(uri)
+
+ if response.is_a?(Net::HTTPSuccess)
+ puts "Working link: #{url}"
+ true
+ else
+ puts "Broken link: #{url} (HTTP #{response.code})"
+ false
+ end
+rescue StandardError => e
+ puts "Error checking #{url}: #{e.message}"
+ false
+end
+
+# Main program
+if ARGV.length != 1
+ puts 'Usage: ruby brokenlinkfinder.rb <URL>'
+ exit
+end
+
+start_url = ARGV.first
+domain = URI(start_url).host
+
+check_links(start_url, domain).each do |broken|
+ puts "Broken: #{broken}"
+end
diff --git a/scripts/gvim b/scripts/gvim
new file mode 100755
index 0000000..5777a7c
--- /dev/null
+++ b/scripts/gvim
@@ -0,0 +1,7 @@
+#!/bin/bash
+# Hack so qutebrowser starts an editor (Helix) in a new ghostty terminal.
+
+declare -r FILE_PATH="$2"
+#echo "$@" > /tmp/params.txt
+
+ghostty -e "hx $FILE_PATH"
diff --git a/scripts/hx.aichat-prompt b/scripts/hx.aichat-prompt
new file mode 100755
index 0000000..4cafcf5
--- /dev/null
+++ b/scripts/hx.aichat-prompt
@@ -0,0 +1,9 @@
+#!/usr/bin/env zsh
+
+declare -xr INSTRUCTIONS='Answer only. If it is code, code only without code-block at the beginning and the end.'
+
+if [[ $# -eq 0 ]]; then
+ aichat "$(hx.prompt). $INSTRUCTIONS"
+else
+ aichat "$@. $INSTRUCTIONS"
+fi
diff --git a/scripts/hx.chatgpt-prompt b/scripts/hx.chatgpt-prompt
new file mode 100755
index 0000000..e4b6047
--- /dev/null
+++ b/scripts/hx.chatgpt-prompt
@@ -0,0 +1,3 @@
+#!/usr/bin/env zsh
+
+chatgpt "$(hx.prompt). Answer only. If it is code, code only without code-block at the beginning and the end."
diff --git a/scripts/hx.goformatter b/scripts/hx.goformatter
new file mode 100755
index 0000000..028fbb2
--- /dev/null
+++ b/scripts/hx.goformatter
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+goimports | gofumpt
diff --git a/scripts/hx.hexai-prompt b/scripts/hx.hexai-prompt
new file mode 100755
index 0000000..ef413c0
--- /dev/null
+++ b/scripts/hx.hexai-prompt
@@ -0,0 +1,9 @@
+#!/usr/bin/env zsh
+
+declare -xr INSTRUCTIONS='Answer only. If it is code, code only without code-block at the beginning and the end.'
+
+if [[ $# -eq 0 ]]; then
+ hexai "$(hx.prompt). $INSTRUCTIONS" 2>/dev/null
+else
+ hexai "$@. $INSTRUCTIONS" 2>/dev/null
+fi
diff --git a/scripts/hx.nvim-copilot-prompt b/scripts/hx.nvim-copilot-prompt
new file mode 100755
index 0000000..dcb2837
--- /dev/null
+++ b/scripts/hx.nvim-copilot-prompt
@@ -0,0 +1,32 @@
+#!/usr/bin/env zsh
+
+declare -r STDIN_FILE=~/.copilot_prompt_stdin.txt
+declare -r INPUT_FILE=~/.copilot_chat_input.txt
+declare -r OUTPUT_FILE=~/.copilot_chat_output.txt
+declare INPUT_PROMPT
+
+if [ -f $OUTPUT_FILE.done ]; then
+ rm $OUTPUT_FILE.done
+fi
+cat > $STDIN_FILE &>/dev/null
+
+if [ $# -eq 0 ]; then
+ INPUT_PROMPT="$(hx.prompt)"
+else
+ INPUT_PROMPT="$@"
+fi
+
+cat <<INPUT_FILE > $INPUT_FILE
+$INPUT_PROMPT for the following:
+
+$(cat $STDIN_FILE)
+
+If the result is code, print out the code only, don't print the \`\`\`-markers around the code block.
+INPUT_FILE
+
+tmux split-window -v "nvim +':CopilotAsk'; mv $OUTPUT_FILE $OUTPUT_FILE.done"
+
+while [ ! -f "$OUTPUT_FILE.done" ]; do
+ sleep 0.2
+done
+sed -n '/^## Copilot/,/^## User/ { /^## Copilot/d; /\[file:/d; /^## User/d; p; }' $OUTPUT_FILE.done
diff --git a/scripts/hx.prompt b/scripts/hx.prompt
new file mode 100755
index 0000000..8dd14dd
--- /dev/null
+++ b/scripts/hx.prompt
@@ -0,0 +1,14 @@
+#!/usr/bin/env zsh
+
+declare -r REPLY_FILE=~/.hx-prompt-reply
+if [ -f "$REPLY_FILE" ]; then
+ rm "$REPLY_FILE"
+fi
+
+tmux split-window -v "touch $REPLY_FILE.tmp; hx $REPLY_FILE.tmp; mv $REPLY_FILE.tmp $REPLY_FILE"
+
+while [ ! -f "$REPLY_FILE" ]; do
+ sleep 0.2
+done
+
+cat "$REPLY_FILE"
diff --git a/scripts/randomnote.rb b/scripts/randomnote.rb
new file mode 100644
index 0000000..b0c1b49
--- /dev/null
+++ b/scripts/randomnote.rb
@@ -0,0 +1,30 @@
+#!/usr/bin/env ruby
+
+NOTES_DIR = "#{ENV['HOME']}/git/foo.zone-content/gemtext/notes"
+BOOK_PATH = "#{ENV['HOME']}/Buecher/Diverse/Search-Inside-Yourself.txt"
+MIN_PERCENTAGE = 80
+MIN_LENGTH = 10
+
+class String
+ CLEAN_PATTERN = [
+ /\d\d\d-\d\d-\d\d/, /[^A-Za-z0-9!.;,?'" @]/,
+ /http.?:\/\/\S+/, /\S+\.gmi/, /^\./, /^\d/,
+ ]
+ def clean
+ CLEAN_PATTERN.each {|p| gsub! p, '' }
+ gsub(/\s+/, ' ').strip
+ end
+ def letter_percentage?(threshold) = threshold <= (100 * count("A-Za-z")) / length
+end
+
+begin
+ srand Random.new_seed
+ puts File.read((Dir["#{NOTES_DIR}/*.gmi"] + [BOOK_PATH]).shuffle.sample)
+ .split("\n")
+ .map(&:clean)
+ .select{ |l| l.length >= MIN_LENGTH }
+ .reject{ |l| l.match?(/(Published at|EMail your comments)/) }
+ .reject{ |l| l.match?(/'|book notes/) }
+ .select{ |l| l.letter_percentage?(MIN_PERCENTAGE) }
+ .shuffle.sample
+end
diff --git a/scripts/taskwarriorfeeder.rb b/scripts/taskwarriorfeeder.rb
new file mode 100644
index 0000000..117ca69
--- /dev/null
+++ b/scripts/taskwarriorfeeder.rb
@@ -0,0 +1,220 @@
+#!/usr/bin/env ruby
+
+require 'optparse'
+require 'digest'
+require 'json'
+require 'set'
+
+PERSONAL_TIMESPAN_D = 30
+WORK_TIMESPAN_D = 14
+WORKTIME_DIR = "#{ENV['HOME']}/git/worktime".freeze
+GOS_DIR = "#{ENV['HOME']}/.gosdir".freeze
+MAX_PENDING_RANDOM_TASKS = 42
+
+def maybe?
+ [true, false].sample
+end
+
+def run_from_personal_device?
+ `uname`.chomp == 'Linux'
+end
+
+def random_count
+ MAX_PENDING_RANDOM_TASKS - `task status:pending +random -work count`.to_i
+end
+
+def notes(notes_dirs, prefix, dry)
+ notes_dirs.each do |notes_dir|
+ Dir["#{notes_dir}/#{prefix}-*"].each do |notes_file|
+ match = File.read(notes_file).strip.match(/(?<due>\d+)? *(?<tag>[A-Z]?[a-z,-:]+) *(?<body>.*)/m)
+ next unless match
+
+ tags = match[:tag].split(',') + [prefix]
+ due = if match[:due].nil?
+ tags.include?('track') ? 'eow' : "#{rand(0..PERSONAL_TIMESPAN_D)}d"
+ else
+ "#{match[:due]}d"
+ end
+ yield tags, match[:body], due
+ File.delete(notes_file) unless dry
+ end
+ end
+end
+
+def random_quote(md_file)
+ tag = File.basename(md_file, '.md').downcase
+ lines = File.readlines(md_file)
+
+ match = lines.first.match(/\((\d+)\)/)
+ timespan = run_from_personal_device? ? PERSONAL_TIMESPAN_D : WORK_TIMESPAN_D
+ timespan = match ? match[1].to_i : timespan
+
+ quote = lines.select { |l| l.start_with? '*' }.map { |l| l.sub(/\* +/, '') }.sample
+ tags = [tag, 'random']
+ tags << 'work' if maybe? and maybe?
+ yield tags, quote.chomp, "#{rand(0..timespan)}d"
+end
+
+def run!(cmd, dry)
+ puts cmd
+ return if dry
+
+ puts `#{cmd}`
+ raise "Command '#{cmd}' failed with #{$?.exitstatus}" if $?.exitstatus != 0
+rescue StandardError => e
+ puts "Error running command '#{cmd}': #{e.message}"
+ exit 1
+end
+
+def skill_add!(skills_str, dry)
+ skills_file = "#{WORKTIME_DIR}/skills.txt"
+ skills_str.split(',').map(&:strip).each { skills[_1.to_s.downcase] = _1 }
+
+ File.foreach(skills_file) do |line|
+ line.chomp!
+ skills[line.downcase] = line
+ end
+ File.open("#{skills_file}.tmp", 'w') do |file|
+ skills.each_value { |skill| file.puts(skill) }
+ end
+ return if dry
+
+ File.rename("#{skills_file}.tmp", skills_file)
+end
+
+def worklog_add!(tag, quote, due, dry)
+ file = "#{WORKTIME_DIR}/wl-#{Time.now.to_i}n.txt"
+ content = "#{due.chomp 'd'} #{tag} #{quote}"
+
+ puts "#{file}: #{content}"
+ File.write(file, content) unless dry
+end
+
+# Queue to Gos https://codeberg.org/snonux/gos
+def gos_queue!(tags, message, dry)
+ tags.delete('share')
+ platforms = []
+ %w[linkedin li mastodon ma noop no].select { tags.include?(_1) }.each do |platform|
+ platforms << platform
+ tags.delete(platform)
+ end
+ unless platforms.empty?
+ platforms = %w[share] + platforms
+ tags = ["#{platforms.join(':')}"] + tags
+ end
+ tags = %w[share] + tags if tags.size == 1 && !tags.first.start_with?('share')
+ tags_str = tags.join(',')
+
+ message = "#{tags_str.empty? ? '' : "#{tags_str} "}#{message}"
+ file = "#{GOS_DIR}/#{Digest::MD5.hexdigest(message)}.txt"
+ puts "Writing #{file} with #{message}"
+ File.write(file, message) unless dry
+end
+
+def task_add!(tags, quote, due, dry)
+ if quote.empty?
+ puts 'Not adding task with empty quote'
+ return
+ end
+ if tags.include?('tr')
+ tags << 'track'
+ tags.delete('tr')
+ end
+ tags << 'work' if tags.include?('mentoring') || tags.include?('productivity')
+ tags.uniq!
+
+ if tags.include?('task')
+ run! "task #{quote}", dry
+ else
+ project = tags.find { |t| t =~ /^[A-Z]/ }
+ project = if project.nil?
+ ''
+ else
+ tags.delete(project)
+ " project:#{project.downcase}"
+ end
+ priority = tags.include?('high') ? 'H' : ''
+ run! "task add due:#{due} priority:#{priority}#{project} +#{tags.join(' +')} '#{quote.gsub("'", '"')}'", dry
+ end
+end
+
+def task_schedule!(id, due, dry)
+ run! "timeout 5s task modify #{id} due:#{due}", dry
+end
+
+def filter_tasks(filter)
+ lines = `task #{filter} 2>/dev/null`.split("\n").drop(1)
+ lines.pop
+ lines.map { |foo| foo.split.first }.each do |id|
+ yield id if id.to_i.positive?
+ end
+end
+
+begin
+ opts = {
+ random_dir: "#{ENV['HOME']}/Notes/random",
+ notes_dirs: "#{ENV['HOME']}/Notes,#{ENV['HOME']}/Notes/Quicklogger,#{ENV['HOME']}/git/worktime",
+ dry_run: false,
+ no_random: false
+ }
+
+ opt_parser = OptionParser.new do |o|
+ o.banner = 'Usage: ruby taskwarriorfeeder.rb [options]'
+ o.on('-d', '--random-dir DIR', 'The random quotes directory') { |v| opts[:random_dir] = v }
+ o.on('-n', '--notes-dirs DIR1,DIR2,...', 'The notes directories') { |v| opts[:notes_dirs] = v }
+ o.on('-D', '--dry-run', 'Dry run mode') { opts[:dry_run] = true }
+ o.on('-R', '--no-randoms', 'No random entries') { opts[:no_random] = true }
+ o.on_tail('-h', '--help', 'Show this help message and exit') { puts o and exit }
+ end
+
+ opt_parser.parse!(ARGV)
+
+ (run_from_personal_device? ? %w[ql pl] : %w[wl]).each do |prefix|
+ notes(opts[:notes_dirs].split(','), prefix, opts[:dry_run]) do |tags, note, due|
+ if tags.include?('skill') || tags.include?('skills')
+ skill_add!(note, opts[:dry_run])
+ elsif tags.include? 'work'
+ worklog_add!(:log, note, due, opts[:dry_run])
+ elsif tags.any? { |tag| tag.start_with?('share') }
+ gos_queue!(tags, note, opts[:dry_run])
+ else
+ task_add!(tags, note, due, opts[:dry_run])
+ end
+ end
+ end
+
+ unless opts[:no_random]
+ count = random_count
+
+ Dir["#{opts[:random_dir]}/*.md"].shuffle.each do |md_file|
+ next unless maybe?
+ break if count <= 0
+
+ random_quote(md_file) do |tags, quote, due|
+ task_add!(tags, quote, due, opts[:dry_run])
+ count -= 1
+ end
+ end
+ end
+
+ if Dir.exist?(GOS_DIR) && !opts[:dry_run]
+ Dir["#{WORKTIME_DIR}/tw-gos-*.json"].each do |tw_gos|
+ JSON.parse(File.read(tw_gos)).each do |entry|
+ gos_queue!(entry['tags'], entry['description'], opts[:dry_run])
+ end
+ File.delete(tw_gos)
+ rescue StandardError => e
+ puts e
+ end
+ end
+
+ # Schedule track tasks to end of week
+ filter_tasks('+track due:') do |id|
+ task_schedule!(id, 'eow', opts[:dry_run])
+ end
+
+ # Randomly schedule other unscheduled tasks
+ filter_tasks('-unsched -nosched -meeting -track due:') do |id|
+ task_schedule!(id, "#{rand(0..PERSONAL_TIMESPAN_D)}d", opts[:dry_run])
+ end
+end
diff --git a/scripts/tmux-edit-send b/scripts/tmux-edit-send
new file mode 100755
index 0000000..05fe62d
--- /dev/null
+++ b/scripts/tmux-edit-send
@@ -0,0 +1,246 @@
+#!/usr/bin/env bash
+set -u -o pipefail
+
+LOG_ENABLED=0
+log_file="${TMPDIR:-/tmp}/tmux-edit-send.log"
+log() {
+ if [ "$LOG_ENABLED" -eq 1 ]; then
+ printf '%s\n' "$*" >> "$log_file"
+ fi
+}
+
+# Read the target pane id from a temp file created by tmux binding.
+read_target_from_file() {
+ local file_path="$1"
+ local pane_id
+ if [ -n "$file_path" ] && [ -f "$file_path" ]; then
+ pane_id="$(sed -n '1p' "$file_path" | tr -d '[:space:]')"
+ # Ensure pane ID has % prefix
+ if [ -n "$pane_id" ] && [[ "$pane_id" != %* ]]; then
+ pane_id="%${pane_id}"
+ fi
+ printf '%s' "$pane_id"
+ fi
+}
+
+# Read the target pane id from tmux environment if present.
+read_target_from_env() {
+ local env_line pane_id
+ env_line="$(tmux show-environment -g TMUX_EDIT_TARGET 2>/dev/null || true)"
+ case "$env_line" in
+ TMUX_EDIT_TARGET=*)
+ pane_id="${env_line#TMUX_EDIT_TARGET=}"
+ # Ensure pane ID has % prefix
+ if [ -n "$pane_id" ] && [[ "$pane_id" != %* ]] && [[ "$pane_id" =~ ^[0-9]+$ ]]; then
+ pane_id="%${pane_id}"
+ fi
+ printf '%s' "$pane_id"
+ ;;
+ esac
+}
+
+# Resolve the target pane id, falling back to the last pane.
+resolve_target_pane() {
+ local candidate="$1"
+ local current_pane last_pane
+
+ current_pane="$(tmux display-message -p "#{pane_id}" 2>/dev/null || true)"
+ log "current pane=${current_pane:-<empty>}"
+
+ # Ensure candidate has % prefix if it's a pane ID
+ if [ -n "$candidate" ] && [[ "$candidate" =~ ^[0-9]+$ ]]; then
+ candidate="%${candidate}"
+ log "normalized candidate to $candidate"
+ fi
+
+ if [ -n "$candidate" ] && [[ "$candidate" == *"#{"* ]]; then
+ log "format target detected, clearing"
+ candidate=""
+ fi
+ if [ -z "$candidate" ]; then
+ candidate="$(tmux display-message -p "#{last_pane}" 2>/dev/null || true)"
+ log "using last pane as fallback: $candidate"
+ elif [ "$candidate" = "$current_pane" ]; then
+ last_pane="$(tmux display-message -p "#{last_pane}" 2>/dev/null || true)"
+ if [ -n "$last_pane" ]; then
+ candidate="$last_pane"
+ log "candidate was current, using last pane: $candidate"
+ fi
+ fi
+ printf '%s' "$candidate"
+}
+
+# Capture the latest multi-line prompt content from the pane.
+capture_prompt_text() {
+ local target="$1"
+ tmux capture-pane -p -t "$target" -S -2000 2>/dev/null | awk '
+ function trim_box(line) {
+ sub(/^ *│ ?/, "", line)
+ sub(/ *│ *$/, "", line)
+ sub(/[[:space:]]+$/, "", line)
+ return line
+ }
+ /^ *│ *→/ && index($0,"INSERT")==0 && index($0,"Add a follow-up")==0 {
+ if (text != "") last = text
+ text = ""
+ capture = 1
+ line = $0
+ sub(/^.*→ ?/, "", line)
+ line = trim_box(line)
+ if (line != "") text = line
+ next
+ }
+ capture {
+ if ($0 ~ /^ *└/) {
+ capture = 0
+ if (text != "") last = text
+ next
+ }
+ if ($0 ~ /^ *│/ && index($0,"INSERT")==0 && index($0,"Add a follow-up")==0) {
+ line = trim_box($0)
+ if (line != "") {
+ if (text != "") text = text " " line
+ else text = line
+ }
+ }
+ }
+ END {
+ if (text != "") last = text
+ if (last != "") print last
+ }
+ '
+}
+
+# Write captured prompt text into the temp file if available.
+prefill_tmpfile() {
+ local tmpfile="$1"
+ local prompt_text="$2"
+ if [ -n "$prompt_text" ]; then
+ printf '%s\n' "$prompt_text" > "$tmpfile"
+ fi
+}
+
+# Ensure the target pane exists before sending keys.
+validate_target_pane() {
+ local target="$1"
+ local pane target_found
+ if [ -z "$target" ]; then
+ log "error: no target pane determined"
+ echo "Could not determine target pane." >&2
+ return 1
+ fi
+ target_found=0
+ log "validate: looking for target='$target' in all panes:"
+ for pane in $(tmux list-panes -a -F "#{pane_id}" 2>/dev/null || true); do
+ log "validate: checking pane='$pane'"
+ if [ "$pane" = "$target" ]; then
+ target_found=1
+ log "validate: MATCH FOUND!"
+ break
+ fi
+ done
+ if [ "$target_found" -ne 1 ]; then
+ log "error: target pane not found: $target"
+ echo "Target pane not found: $target" >&2
+ return 1
+ fi
+ log "validate: target pane validated successfully"
+}
+
+# Send temp file contents to the target pane line by line.
+send_content() {
+ local target="$1"
+ local tmpfile="$2"
+ local prompt_text="$3"
+ local first_line=1
+ local line
+ log "send_content: target=$target, prompt_text='$prompt_text'"
+ while IFS= read -r line || [ -n "$line" ]; do
+ log "send_content: read line='$line'"
+ if [ "$first_line" -eq 1 ] && [ -n "$prompt_text" ]; then
+ if [[ "$line" == "$prompt_text"* ]]; then
+ local old_line="$line"
+ line="${line#"$prompt_text"}"
+ log "send_content: stripped prompt, was='$old_line' now='$line'"
+ fi
+ fi
+ first_line=0
+ log "send_content: sending line='$line'"
+ tmux send-keys -t "$target" -l "$line"
+ tmux send-keys -t "$target" Enter
+ done < "$tmpfile"
+ log "sent content to $target"
+}
+
+# Main entry point.
+main() {
+ local target_file="${1:-}"
+ local target
+ local editor="${EDITOR:-vi}"
+ local tmpfile
+ local prompt_text
+
+ log "=== tmux-edit-send starting ==="
+ log "target_file=$target_file"
+ log "EDITOR=$editor"
+
+ target="$(read_target_from_file "$target_file" || true)"
+ if [ -n "$target" ]; then
+ log "file target=${target:-<empty>}"
+ rm -f "$target_file"
+ fi
+ if [ -z "$target" ]; then
+ target="${TMUX_EDIT_TARGET:-}"
+ fi
+ log "env target=${target:-<empty>}"
+ if [ -z "$target" ]; then
+ target="$(read_target_from_env || true)"
+ fi
+ log "tmux env target=${target:-<empty>}"
+ target="$(resolve_target_pane "$target")"
+ log "fallback target=${target:-<empty>}"
+
+ tmpfile="$(mktemp)"
+ log "created tmpfile=$tmpfile"
+ if [ ! -f "$tmpfile" ]; then
+ log "ERROR: mktemp failed to create file"
+ echo "ERROR: mktemp failed" >&2
+ exit 1
+ fi
+ mv "$tmpfile" "${tmpfile}.md" 2>&1 | while read -r line; do log "mv output: $line"; done
+ tmpfile="${tmpfile}.md"
+ log "renamed to tmpfile=$tmpfile"
+ if [ ! -f "$tmpfile" ]; then
+ log "ERROR: tmpfile does not exist after rename"
+ echo "ERROR: tmpfile rename failed" >&2
+ exit 1
+ fi
+ trap 'rm -f "$tmpfile"' EXIT
+
+ log "capturing prompt text from target=$target"
+ prompt_text="$(capture_prompt_text "$target")"
+ log "captured prompt_text='$prompt_text'"
+ prefill_tmpfile "$tmpfile" "$prompt_text"
+ log "prefilled tmpfile"
+
+ log "launching editor: $editor $tmpfile"
+ "$editor" "$tmpfile"
+ local editor_exit=$?
+ log "editor exited with status $editor_exit"
+
+ if [ ! -s "$tmpfile" ]; then
+ log "empty file, nothing sent"
+ exit 0
+ fi
+
+ log "tmpfile contents:"
+ log "$(cat "$tmpfile")"
+
+ log "validating target pane"
+ validate_target_pane "$target"
+ log "sending content to target=$target"
+ send_content "$target" "$tmpfile" "$prompt_text"
+ log "=== tmux-edit-send completed ==="
+}
+
+main "$@"
diff --git a/scripts/wol-f3s b/scripts/wol-f3s
new file mode 100755
index 0000000..263763b
--- /dev/null
+++ b/scripts/wol-f3s
@@ -0,0 +1,86 @@
+#!/bin/bash
+# Wake-on-LAN and shutdown script for f3s cluster (f0, f1, f2)
+#
+# Usage:
+# wol-f3s # Wake all three Beelinks
+# wol-f3s f0 # Wake only f0
+# wol-f3s f1 # Wake only f1
+# wol-f3s f2 # Wake only f2
+# wol-f3s shutdown # Shutdown all three Beelinks
+
+# MAC addresses
+F0_MAC="e8:ff:1e:d7:1c:ac" # f0 (192.168.1.130)
+F1_MAC="e8:ff:1e:d7:1e:44" # f1 (192.168.1.131)
+F2_MAC="e8:ff:1e:d7:1c:a0" # f2 (192.168.1.132)
+
+# IP addresses
+F0_IP="192.168.1.130"
+F1_IP="192.168.1.131"
+F2_IP="192.168.1.132"
+
+# SSH user
+SSH_USER="paul"
+
+# Broadcast address for your LAN
+BROADCAST="192.168.1.255"
+
+wake() {
+ local name=$1
+ local mac=$2
+
+ if [[ "$mac" == "XX:XX:XX:XX:XX:XX" ]]; then
+ echo "⚠️ $name MAC address not configured yet"
+ return 1
+ fi
+
+ echo "Sending WoL packet to $name ($mac)..."
+ wol -i "$BROADCAST" "$mac"
+}
+
+shutdown_host() {
+ local name=$1
+ local ip=$2
+
+ echo "Shutting down $name ($ip)..."
+ ssh -o ConnectTimeout=5 "$SSH_USER@$ip" "doas poweroff" 2>/dev/null && \
+ echo " ✓ Shutdown command sent to $name" || \
+ echo " ✗ Failed to reach $name (already down?)"
+}
+
+ACTION="${1:-all}"
+
+case "$ACTION" in
+ f0)
+ wake "f0" "$F0_MAC"
+ ;;
+ f1)
+ wake "f1" "$F1_MAC"
+ ;;
+ f2)
+ wake "f2" "$F2_MAC"
+ ;;
+ all|"")
+ wake "f0" "$F0_MAC"
+ wake "f1" "$F1_MAC"
+ wake "f2" "$F2_MAC"
+ ;;
+ shutdown|poweroff|down)
+ # This is to mute Gogios alerts for a day
+ ssh rex@blowfish.buetow.org touch /tmp/f3s_taken_down
+ ssh rex@fishfinger.buetow.org touch /tmp/f3s_taken_down
+ shutdown_host "f0" "$F0_IP"
+ shutdown_host "f1" "$F1_IP"
+ shutdown_host "f2" "$F2_IP"
+ echo ""
+ echo "✓ Shutdown commands sent to all machines."
+ exit 0
+ ;;
+ *)
+ echo "Usage: $0 [f0|f1|f2|all|shutdown]"
+ exit 1
+ ;;
+esac
+
+echo ""
+echo "✓ WoL packets sent. Machines should boot in a few seconds."
+echo " Wait ~30 seconds, then try: ssh paul@192.168.1.130"