#!/bin/zsh
# velcro — keep network shares stuck to your Mac.
# Remounts SMB and NFS shares after Wi-Fi drops, sleep, or network changes. No popups.
emulate -L zsh
setopt pipe_fail
zmodload zsh/system
zmodload zsh/zselect
zmodload zsh/datetime

VERSION=0.11.0
SELF=${0:A}
LABEL=io.github.dgitco.velcro
CONF_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/velcro
CONF=$CONF_DIR/mounts
PAUSE=$CONF_DIR/paused
LOCK=$CONF_DIR/.lock
LOG=$HOME/Library/Logs/velcro.log
PLIST=$HOME/Library/LaunchAgents/$LABEL.plist
BIN=$HOME/.local/bin/velcro
INTERVAL=60
APP_EXEC=velcro.app/Contents/MacOS/
# Field separator for scan output. Not a tab: read collapses repeated whitespace, losing empty fields.
SEP=$'\x1f'
# Import and send (see "velcro help"). Personal paths live in $SETTINGS, never in this script.
SETTINGS=$CONF_DIR/settings
SUPPORT=$HOME/Library/Application\ Support/velcro
QUEUE=$SUPPORT/import-queue
IMPORT_STATUS=$SUPPORT/import.status
IMPORT_LOCK=$CONF_DIR/.import.lock
IMPORT_AGAIN=$CONF_DIR/.import.again
IMPORT_LABEL=$LABEL.import
# The import agent also looks every few minutes, so a recorder whose mount was missed (asleep, a lost
# notification) is imported while it stays plugged in. Without a registered recorder it exits at once.
IMPORT_INTERVAL=300
IMPORT_PLIST=$HOME/Library/LaunchAgents/$IMPORT_LABEL.plist
MANIFEST=.velcro-import.jsonl
CLIPS=$SUPPORT/clips
# What `velcro send` put in the inbox, so cleaning never touches anything else there.
SENT=$SUPPORT/sent
# Mac mic (see "velcro help"): velcro.app writes what the passthrough is doing here.
MIC_STATUS=$SUPPORT/mic.status
MIC_HELPER=${SELF:h}/velcro-mic-host
# "velcro mic on" leaves this for velcro.app: use this Mac's microphone even if another Mac took over.
MIC_TAKE=$CONF_DIR/.mic-take
# Mac clipboard (see "velcro help"): velcro.app writes what it last sent here.
CLIP_STATUS=$SUPPORT/clip.status
CLIP_HELPER=${SELF:h}/velcro-clip-host
# Transcribe (see "velcro help"): talks to Claude Code's speech-to-text with your own login.
TRANSCRIBE_HELPER=${SELF:h}/velcro-transcribe
# Recordings (see "velcro help"): transcribe, tidy and deliver what import brought in.
RECORDINGS_HELPER=${SELF:h}/velcro-recordings
CRM_HELPER=${SELF:h}/velcro-crm

log() { print -r -- "$(date '+%F %T') $*" >>"$LOG"; }

# Config lines: <smb or nfs url> [fallback host ...]
# e.g.  smb://me@nas.tailnet.ts.net/home
#       smb://me@192.168.1.10/media nas.tailnet.ts.net
#       nfs://nas.local/volume1/photos
entries() {
  [[ -r $CONF ]] || return 0
  grep -vE '^[[:space:]]*(#|$)' "$CONF"
}

# Split "smb://user@host/share" or "nfs://host/export/path" into globals:
# proto user host share (URL-encoded, as in the config) name (what Finder shows).
parse() {
  proto=${1%%://*} user= host= share= name=
  [[ $proto == smb || $proto == nfs ]] || return 1
  local url=${1#*://}
  if [[ $url == *@* ]]; then user=${url%%@*}; url=${url#*@}; fi
  host=${url%%/*}
  share=${url#*/}
  [[ -n $host && -n $share && $share != "$url" ]] || return 1
  if [[ $proto == nfs ]]; then name=${share:t}; else name=$share; fi
  name=${name//\%20/ }
}

fstype() { [[ $1 == nfs ]] && print nfs || print smbfs; }
port()   { [[ $1 == nfs ]] && print 2049 || print 445; }

# Where is <share> mounted from any of <hosts...>? Prints the mount point.
# Reads `mount` output only; never touches the share, so a dead network can't hang it.
# smbfs lines look like "//me@host/My%20Share on /Volumes/My Share (smbfs, ...)",
# nfs lines like "host:/export/My Share on /Volumes/My Share (nfs, ...)".
mountpoint_of() {
  local proto=$1 share=$2 fs=$(fstype $1) h line; shift 2
  local -a pats
  for h in "$@"; do
    if [[ $proto == nfs ]]; then pats=(-e "$h:/${share//\%20/ } on ")
    else pats=(-e "@$h/$share on " -e "//$h/$share on "); fi
    line=$(mount -t $fs | grep -iF $pats | head -1)
    if [[ -n $line ]]; then
      line=${line#* on }
      print -r -- "${line% \($fs,*}"
      return 0
    fi
  done
  return 1
}

reachable() { nc -z -G 2 "$2" $(port $1) </dev/null >/dev/null 2>&1; }

# One line per share: state, share name, mount point, first host, config line (SEP-separated).
# state: mounted | reachable | offline. With "fast", unmounted shares are "offline" without probing.
scan() {
  local fast=$1 line state mp h
  local -a words hosts
  entries | while IFS= read -r line; do
    words=(${(z)line})
    parse "${words[1]}" || continue
    hosts=($host ${words[2,-1]})
    if mp=$(mountpoint_of $proto "$share" $hosts); then
      state=mounted
    else
      state=offline mp=
      if [[ -z $fast ]]; then
        for h in $hosts; do reachable $proto "$h" && { state=reachable; break; }; done
      fi
    fi
    print -r -- "$state$SEP$name$SEP$mp$SEP$host$SEP$line"
  done
}

mount_share() {
  local url=$1
  # Errors go to the log, never to a dialog. A login prompt only appears if the keychain has no password.
  osascript -e 'on run argv' -e 'try' -e 'mount volume (item 1 of argv)' \
    -e 'on error m' -e 'log m' -e 'end try' -e 'end run' "$url" </dev/null >/dev/null 2>>"$LOG"
}

cmd_run() {
  [[ -e $PAUSE || ! -r $CONF ]] && return 0
  # One pass at a time: velcro.app, the agent, and a terminal can all ask at once.
  local lockfd
  : >>"$LOCK"
  zsystem flock -t 0 -f lockfd "$LOCK" 2>/dev/null || return 0
  # Don't stack login prompts if one is already waiting.
  pgrep -f "osascript.*mount volume" >/dev/null && return 0
  [[ -f $LOG && $(stat -f %z "$LOG") -gt 1048576 ]] && mv -f "$LOG" "$LOG.1"

  local line h
  local -a words hosts
  entries | while IFS= read -r line; do
    words=(${(z)line})
    parse "${words[1]}" || { log "bad entry: $line"; continue; }
    hosts=($host ${words[2,-1]})
    mountpoint_of $proto "$share" $hosts >/dev/null && continue
    for h in $hosts; do
      reachable $proto "$h" || continue
      log "mount $proto://$h/$share"
      mount_share "$proto://${user:+$user@}$h/$share"
      break
    done
  done
  return 0
}

cmd_status() {
  local st name mp h line state agent=
  pgrep -qf "$APP_EXEC" && agent="velcro.app (on network change, wake, and a timer)"
  launchctl print "gui/$UID/$LABEL" >/dev/null 2>&1 &&
    agent+="${agent:+ + }LaunchAgent (every ${INTERVAL}s + on network change)"
  print "agent:  ${agent:-not running  →  open velcro.app, or velcro install}"
  [[ -e $PAUSE ]] && print "state:  paused  →  velcro resume" || print "state:  active"
  print
  # Grouped by server: each line in the config is one shared folder on that server.
  local -a rows; local row srv
  rows=("${(@f)$(scan)}"); [[ -z ${rows[1]} ]] && rows=()
  for srv in ${(u)${rows[@]#*$SEP*$SEP*$SEP}%%$SEP*}; do
    print -r -- "$srv"
    for row in $rows; do
      IFS=$SEP read -r st name mp h line <<<"$row"
      [[ $h == "$srv" ]] || continue
      case $st in
        mounted)   state="● mounted" ;;
        reachable) state="◐ reachable, not mounted" ;;
        *)         state="○ offline" ;;
      esac
      printf '  %-26s %-22s %s\n' "$state" "$name" "$mp"
    done
  done
  [[ -s $CONF ]] || print "no shares  (add one with velcro add smb://user@host/share; not needed for import or send)"

  local dest=$(folder_setting import.dest) inbox=$(folder_setting send.inbox)
  local -a regs=("${(@f)$(setting_all recorder)}") waiting=("$QUEUE"/[^.]*(N.))
  [[ -z ${regs[1]} ]] && regs=()
  if (( ${#regs} )) || [[ -n $dest ]]; then
    print
    print -rn -- "import  ${#regs} recorder(s) → ${dest:-(no folder yet: velcro set import.dest …)}"
    if [[ -z $dest ]]; then print; elif on_mounted_volume "$dest"; then print "  ● attached"; else print "  ○ not attached"; fi
    local sub=$(setting import.folder) alt
    print -r -- "        from ${sub:-the whole drive}, $(is_on import.delete && print "deleted from the recorder once verified" || print "kept on the recorder")$(is_on import.auto || print ", only with velcro import")"
    alt=$(renamed_mount "$dest") && print -r -- "        is the share mounted as $alt? then: velcro set import.dest ${dest/#${alt%-*}/$alt}"
    if (( ${#regs} )) && is_on import.auto && ! pgrep -qf "$APP_EXEC" && ! launchctl print "gui/$UID/$IMPORT_LABEL" >/dev/null 2>&1; then
      print -r -- "        ○ nothing imports a recorder plugged in now  →  open velcro.app, or velcro install"
    fi
    (( ${#waiting} )) && print -r -- "        ${#waiting} recording(s) waiting on this Mac until that folder is attached"
  fi
  if [[ -n $inbox ]]; then
    (( ${#regs} )) || [[ -n $dest ]] || print
    print -rn -- "send    $inbox → $(remote_path "$inbox")"
    if on_mounted_volume "$inbox"; then print "  ● attached"; else print "  ○ not attached"; fi
  fi
  return 0
}

cmd_add() {
  [[ -n $1 ]] || { print -u2 "usage: velcro add smb://user@host/share | nfs://host/path [fallback-host ...]"; return 2; }
  local url=${1// /%20} line
  parse "$url" || { print -u2 "not an smb:// or nfs:// url: $1"; return 2; }
  mkdir -p "$CONF_DIR"
  entries | while IFS= read -r line; do
    [[ ${${(z)line}[1]} == "$url" ]] && { print "already added: $url"; return 0; }
  done
  print -r -- "$url${2:+ ${@[2,-1]}}" >>"$CONF"
  print "added: $url${2:+ (fallback: ${@[2,-1]})}"
  cmd_run
}

# Drop the share with this exact url, or with this name if only one share has it.
cmd_rm() {
  [[ -n $1 && -r $CONF ]] || { print -u2 "usage: velcro rm <url or share name>"; return 2; }
  local arg=${1// /%20} line
  local -a words kept gone
  while IFS= read -r line || [[ -n $line ]]; do
    words=(${(z)line})
    if [[ ! $line =~ '^[[:space:]]*(#|$)' ]] && parse "${words[1]}" &&
       [[ ${words[1]} == "$arg" || $name == "$1" ]]; then
      gone+=("${words[1]}")
    else
      kept+=("$line")
    fi
  done <"$CONF"
  (( ${#gone} )) || { print -u2 "no match: $1"; return 1; }
  gone=(${(u)gone})
  if (( ${#gone} > 1 )); then
    print -u2 "more than one share is called \"$1\"; use its url:"
    print -rlu2 -- "  "${^gone}
    return 1
  fi
  if (( ${#kept} )); then print -rl -- $kept >"$CONF.tmp"; else : >"$CONF.tmp"; fi
  mv -f "$CONF.tmp" "$CONF"
  print "removed: ${gone[1]}  (still mounted? eject it in Finder)"
}

# True when this copy of velcro lives inside velcro.app.
in_app() { [[ $SELF == *.app/Contents/Resources/velcro ]]; }

cmd_install() {
  mkdir -p "${BIN:h}" "${LOG:h}" "$CONF_DIR"
  remove_swiftbar
  if in_app; then
    # The app does the reconnecting, so the agent goes and the command points into the app.
    stop_agent
    ln -sfh "$SELF" "$BIN" && print "linked: $BIN → $SELF"
    print "velcro.app reconnects your shares while it runs (it can start at login)"
    # Importing stays with launchd: a recorder plugged in while the app is quit, crashed or not yet
    # started after a restart is otherwise never imported, and nothing says so. While the app runs,
    # the agent leaves importing to it (see cmd_import).
    install_import_agent
  else
    # Replace, don't write through: $BIN may be a link into velcro.app.
    [[ $SELF == "${BIN:A}" ]] || { rm -f "$BIN"; cp "$SELF" "$BIN"; chmod +x "$BIN"; }
    # velcro transcribe finds its helper next to the command.
    local helper
    for helper in $TRANSCRIBE_HELPER $RECORDINGS_HELPER $CRM_HELPER ${SELF:h}/velcro_crm.py; do
      [[ -f $helper && ${helper:A} != ${BIN:h:A}/${helper:t} ]] &&
        cp -f "$helper" "${BIN:h}/${helper:t}" && chmod +x "${BIN:h}/${helper:t}"
    done
    mkdir -p "${PLIST:h}"
    cat >"$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>$LABEL</string>
  <key>ProgramArguments</key><array><string>$BIN</string><string>run</string></array>
  <key>RunAtLoad</key><true/>
  <key>StartInterval</key><integer>$INTERVAL</integer>
  <key>WatchPaths</key><array><string>/Library/Preferences/SystemConfiguration</string></array>
  <key>StandardOutPath</key><string>$LOG</string>
  <key>StandardErrorPath</key><string>$LOG</string>
</dict>
</plist>
EOF
    launchctl bootout "gui/$UID/$LABEL" 2>/dev/null
    # bootout is asynchronous; wait until the old agent is gone
    local i; for i in {1..20}; do launchctl print "gui/$UID/$LABEL" >/dev/null 2>&1 || break; sleep 0.25; done
    launchctl bootstrap "gui/$UID" "$PLIST" && print "installed: $BIN (agent $LABEL)"
    install_import_agent
  fi
  [[ :$PATH: == *:${BIN:h}:* ]] || print "note: add ${BIN:h} to your PATH"
}

cmd_uninstall() {
  stop_agent
  remove_swiftbar
  # The command: a link into velcro.app, or the copy a command-line install made.
  if [[ -L $BIN ]] || { [[ -f $BIN ]] && grep -q '^# velcro — keep network shares' "$BIN"; }; then
    rm -f "$BIN" && print "removed $BIN"
  fi
  [[ -f ${BIN:h}/velcro-sha ]] && rm -f "${BIN:h}/velcro-sha"
  [[ -f ${BIN:h}/velcro-transcribe ]] && grep -q '^"""velcro-transcribe:' "${BIN:h}/velcro-transcribe" &&
    rm -f "${BIN:h}/velcro-transcribe"
  [[ -f ${BIN:h}/velcro-crm ]] && grep -q '^"""velcro-crm:' "${BIN:h}/velcro-crm" && {
    rm -f "${BIN:h}/velcro-crm" "${BIN:h}/velcro_crm.py"
  }
  print "agent removed (settings kept in $CONF_DIR; delete that folder too to forget them)"
}

stop_agent() {
  launchctl bootout "gui/$UID/$LABEL" 2>/dev/null
  launchctl bootout "gui/$UID/$IMPORT_LABEL" 2>/dev/null
  rm -f "$PLIST" "$IMPORT_PLIST"
}

# Without the app, a second agent runs "velcro import --auto" whenever any volume mounts:
# a recorder plugged in, or the destination's drive or share coming back for recordings kept on this Mac.
# Separate from the reconnect agent so a long import never holds up reconnecting.
install_import_agent() {
  mkdir -p "${IMPORT_PLIST:h}" || return 1
  cat >"$IMPORT_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>$IMPORT_LABEL</string>
  <key>ProgramArguments</key><array><string>$BIN</string><string>import</string><string>--auto</string></array>
  <key>StartOnMount</key><true/>
  <key>StartInterval</key><integer>$IMPORT_INTERVAL</integer>
  <key>StandardOutPath</key><string>$LOG</string>
  <key>StandardErrorPath</key><string>$LOG</string>
</dict>
</plist>
EOF
  launchctl bootout "gui/$UID/$IMPORT_LABEL" 2>/dev/null
  local i; for i in {1..20}; do launchctl print "gui/$UID/$IMPORT_LABEL" >/dev/null 2>&1 || break; sleep 0.25; done
  launchctl bootstrap "gui/$UID" "$IMPORT_PLIST" && print "installed: agent $IMPORT_LABEL (import on mount)"
}

# velcro 0.2 drew its menu with a SwiftBar plugin; velcro.app replaces it.
remove_swiftbar() {
  local dir=$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)
  local plugin=${dir:-$HOME/Library/Application Support/SwiftBar/Plugins}/velcro.10s.sh
  rm -rf "$HOME/.local/share/velcro/icons"
  rmdir "$HOME/.local/share/velcro" 2>/dev/null
  [[ -e $plugin ]] || return 0
  rm -f "$plugin"
  pgrep -xq SwiftBar && open -g "swiftbar://refreshallplugins" 2>/dev/null
  print "removed the old SwiftBar menu plugin"
}

cmd_pause() {
  mkdir -p "$CONF_DIR"; : >"$PAUSE"
  print "paused — eject shares in Finder; they won't come back until 'velcro resume'"
}
cmd_resume() { rm -f "$PAUSE"; print "resumed"; cmd_run; }
cmd_logs()   { [[ -f $LOG ]] && tail -n ${1:-30} "$LOG" || print "no log yet"; }

# For velcro.app: "paused" or "active", then one scan line per share.
cmd_scan() { [[ -e $PAUSE ]] && print paused || print active; scan $1; }

# For velcro.app: the files it shares with this script.
cmd_env() {
  print -rl -- "VERSION=$VERSION" "CONF=$CONF" "PAUSE=$PAUSE" "LOG=$LOG" "BIN=$BIN" "SELF=$SELF" \
    "SETTINGS=$SETTINGS" "QUEUE=$QUEUE" "IMPORT_STATUS=$IMPORT_STATUS" "MIC_STATUS=$MIC_STATUS" \
    "CLIP_STATUS=$CLIP_STATUS"
}

# ---------------------------------------------------------------------------------------------
# Settings: ~/.config/velcro/settings, one "key = value" per line. recorder and map may repeat.
#   import.dest   = ~/Recordings                    where recordings go: any folder, on this Mac or a share
#   import.ext    = wav mp3 m4a                     file types to take from a recorder
#   import.folder = RECORD                          only this folder on the recorder (and below)
#   import.delete = yes                             delete from the recorder once verified
#   import.auto   = yes                             import as soon as a recorder is plugged in
#   recorder      = uuid:<VolumeUUID> <label>       or name:<volume name>
#   send.inbox    = /Volumes/home/inbox             where "velcro send" puts files: a share, Dropbox ...
#   send.keep     = 14                              days before the inbox is cleaned; 0 keeps all
#   map           = /Volumes/home -> /mnt/nas/me    how another machine sees a local path
#   mic.host      = devbox                          SSH host that gets this Mac's microphone
#   mic.on        = no                              send it while something there records
#   mic.warm      = 0                               seconds the mic stays open after a recording
#   clip.host     = devbox                          SSH host that gets images copied on this Mac
#   clip.on       = no                              send each one as it's copied
#   transcribe.lang     = en                        language spoken in files for "velcro transcribe"
#   transcribe.engine   = claude codex              engines to try, in order, each if signed in
#   transcribe.login    = ~/.claude                 Claude Code folder whose login it uses; may repeat
#   transcribe.codex    = ~/.codex                  Codex folder whose ChatGPT login it uses
#   transcribe.fallback = whisper-cli -f            command run with the file when the engines fail; empty = none
#   recordings.dir      = /mnt/nas/me/recordings    folder with .velcro-import.jsonl (default: import.dest)
#   recordings.out      = /mnt/nas/me/transcripts   where transcripts go (default: <recordings.dir>/_velcro)
#   recordings.engine   = whisper-cli -nt -f        a command line printing the text ({file} {json}), claude or codex
#   recordings.tidy     = ~/bin/tidy                command run after transcribing (VELCRO_* variables)
#   recordings.deliver  = ~/bin/deliver             command run after tidying
#   recordings.done     = ~/bin/meeting-done        command run once per import, when all its recordings are done
#   recordings.alert    = ~/bin/alert               command run with a line of text from the 3rd failure on
#   recordings.since    = 2026-10-21                only recordings imported at or after this time
#   recordings.timeout  = 10800                     seconds the engine may take (1800 + half the recording)

KEYS=(import.dest import.ext import.folder import.delete import.auto recorder send.inbox send.keep map
  mic.host mic.on mic.warm clip.host clip.on transcribe.lang transcribe.engine transcribe.login transcribe.codex transcribe.fallback
  recordings.dir recordings.out recordings.engine recordings.tidy recordings.deliver recordings.done recordings.alert recordings.since
  recordings.timeout crm.url crm.approve)
typeset -A DEFAULTS=(
  import.ext 'wav mp3 m4a aac flac wma ogg'
  import.delete yes
  import.auto yes
  mic.on no
  mic.warm 0
  clip.on no
  send.keep 14
  transcribe.lang en
  transcribe.engine 'claude codex'
)

# Every value of <key>, one per line, or its default when the file has none.
setting_all() {
  setopt local_options extended_glob
  local key=$1 line k v found=
  if [[ -r $SETTINGS ]]; then
    while IFS= read -r line || [[ -n $line ]]; do
      [[ $line == [[:space:]]#(\#*|) || $line != *=* ]] && continue
      k=${${line%%=*}//[[:space:]]/}
      [[ $k == "$key" ]] || continue
      v=${${line#*=}##[[:space:]]#}
      print -r -- "${v%%[[:space:]]#}"
      found=1
    done <"$SETTINGS"
  fi
  [[ -n $found || -z ${DEFAULTS[$key]} ]] || print -r -- "${DEFAULTS[$key]}"
}
# The first value. (No "| head": with pipe_fail, a writer cut off early turns into a failure.)
setting() { local -a v=("${(@f)$(setting_all "$1")}"); print -r -- "${v[1]}"; }
is_on()   { [[ $(setting $1) == (yes|on|true|1) ]]; }
# A folder setting, with a leading ~ meaning the home folder (Settings stores what was typed).
folder_setting() { local p=$(setting $1); print -r -- "${p/#\~/$HOME}"; }

# For velcro.app and scripts: every setting as key=value, repeated keys on their own lines.
cmd_settings() {
  local key v
  for key in $KEYS; do
    setting_all $key | while IFS= read -r v; do print -r -- "$key=$v"; done
  done
}

# velcro set <key> [value ...]: replace every line of <key>; no value goes back to the default.
cmd_set() {
  local append=
  [[ $1 == --add ]] && { append=1; shift; }
  local key=$1 v line; shift 2>/dev/null
  (( ${KEYS[(Ie)$key]} )) || { print -u2 "unknown setting: $key (one of: $KEYS)"; return 2; }
  [[ $key == (transcribe.(fallback|lang|codex)|recordings.*|crm.url|crm.approve) ]] && (( $# > 1 )) &&
    { print -u2 "$key takes one value; quote a command line: velcro set $key \"whisper-cli -nt -f\""; return 2; }
  for v in "$@"; do
    case $key in
      crm.url)
        local py
        py=$(transcribe_python) || return 1
        "$py" "$CRM_HELPER" --url "$v" pending >/dev/null || return 2
        ;;
      import.delete|import.auto) [[ $v == (yes|no) ]] || { print -u2 "$key is yes or no"; return 2; } ;;
      import.folder) [[ $v != /* && $v != (..|*/..|../*|*/../*) ]] || { print -u2 "import.folder is a folder on the recorder, like RECORD"; return 2; } ;;
      send.keep) [[ $v == <-> ]] || { print -u2 "send.keep is a number of days"; return 2; } ;;
      mic.on|clip.on) [[ $v == (yes|no) ]] || { print -u2 "$key is yes or no"; return 2; } ;;
      mic.warm) [[ $v == <-> ]] || { print -u2 "mic.warm is a number of seconds"; return 2; } ;;
      mic.host|clip.host) [[ -n $v && $v != -* && $v != *[[:space:]]* ]] || { print -u2 "$key is an SSH host, like devbox or me@devbox.local"; return 2; } ;;
      map) [[ $v == (/|\~/)?*' -> '/* ]] || { print -u2 "map looks like: /Volumes/home -> /mnt/nas/me  (the left side may start with ~/)"; return 2; } ;;
      transcribe.lang) [[ $v == [a-z][a-z](|-[A-Za-z][A-Za-z]) ]] || { print -u2 "transcribe.lang is a language code, like en or ko"; return 2; } ;;
      transcribe.login) [[ $v == (/|\~/)?* ]] || { print -u2 "transcribe.login is a Claude Code folder, like ~/.claude"; return 2; } ;;
      transcribe.codex) [[ $v == (/|\~/)?* ]] || { print -u2 "transcribe.codex is a Codex folder, like ~/.codex"; return 2; } ;;
      transcribe.engine) [[ $v == (claude|codex) && ${@[(Ie)$v]} == ${@[(ie)$v]} ]] || { print -u2 "transcribe.engine is claude, codex, or both in the order to try: velcro set transcribe.engine codex claude"; return 2; } ;;
      recordings.(dir|out)) [[ $v == (/|\~/)?* ]] || { print -u2 "$key is a folder, like /mnt/nas/me/recordings"; return 2; } ;;
      recordings.timeout) [[ $v == <1-> ]] || { print -u2 "recordings.timeout is a number of seconds"; return 2; } ;;
      recordings.since) [[ $v == <1900-2999>-<01-12>-<01-31>* ]] || { print -u2 "recordings.since is a date or time, like 2026-10-21 or 2026-10-21T09:00"; return 2; } ;;
      recorder) [[ $v == (uuid|name):?* ]] || { print -u2 "recorder is uuid:<VolumeUUID> or name:<volume name>"; return 2; } ;;
    esac
  done
  mkdir -p "$CONF_DIR"
  local -a kept
  if [[ -r $SETTINGS ]]; then
    while IFS= read -r line || [[ -n $line ]]; do
      [[ -z $append && $line == *=* && ${${line%%=*}//[[:space:]]/} == "$key" ]] || kept+=("$line")
    done <"$SETTINGS"
  else
    kept=("# velcro settings: one \"key = value\" per line. See 'velcro help'.")
  fi
  for v in "$@"; do kept+=("$key = $v"); done
  print -rl -- "${kept[@]}" >"$SETTINGS.tmp" && mv -f "$SETTINGS.tmp" "$SETTINGS"
}

# Tell the user. velcro.app turns "notify" lines into notifications; a terminal gets the text;
# a background run (the LaunchAgent) posts a notification itself.
notify() {
  log "$*"
  if [[ -n $VELCRO_APP ]]; then print -r -- "notify$SEP$*"
  elif [[ -t 1 ]]; then print -r -- "$*"
  else osascript -e 'on run argv' -e 'display notification (item 1 of argv) with title "velcro"' \
         -e 'end run' "$*" </dev/null >/dev/null 2>&1
  fi
}

# Mount points in `mount` output, one per line. Reading it never touches a share.
mount_points() {
  local line mp
  mount | while IFS= read -r line; do
    mp=${line#* on }
    print -r -- "${mp% \(*}"
  done
}

# Paths under /Volumes count only while their volume is mounted; otherwise they'd be a
# leftover folder on this Mac, or hang on a share that just dropped. Other paths always count.
on_mounted_volume() {
  local p=${1%/} mp
  [[ $p == /Volumes/?* ]] || return 0
  # Loop over a list, not a pipe: returning mid-pipe breaks the writer, and pipe_fail reports that.
  for mp in "${(@f)$(mount_points)}"; do
    [[ $mp == /Volumes/?* && ($p == "$mp" || $p == "$mp"/*) ]] && return 0
  done
  return 1
}

# /Volumes/home/x when "home" isn't mounted but "home-1" is: macOS renamed the share's mount point
# (usually because a leftover /Volumes/home folder was in the way). Prints /Volumes/home-1.
renamed_mount() {
  [[ $1 == /Volumes/?* ]] || return 1
  local root=/Volumes/${${1#/Volumes/}%%/*} mp
  on_mounted_volume "$1" && return 1
  for mp in "${(@f)$(mount_points)}"; do
    [[ $mp == "$root"-<-> ]] && { print -r -- "$mp"; return 0; }
  done
  return 1
}

# On a mounted drive or share, missing folders are created. Elsewhere only the last folder is, so a
# setting like ~/Dropbox/inbox on a Mac without Dropbox fails instead of quietly making ~/Dropbox.
ready_dir() {
  [[ -n $1 ]] && on_mounted_volume "$1" || return 1
  [[ -d $1 ]] && return 0
  if [[ $1 == /Volumes/?* ]]; then mkdir -p "$1" 2>/dev/null; else [[ -d ${1:h} ]] && mkdir "$1" 2>/dev/null; fi
}

# How another machine sees <path>, by the longest matching "map" rule; unchanged without one.
remote_path() {
  local p=$1 m from to best= best_to=
  setting_all map | while IFS= read -r m; do
    from=${${m%% -> *}%/} to=${${m#* -> }%/}
    from=${from/#\~/$HOME}
    if [[ ($p == "$from" || $p == "$from"/*) && ${#from} -gt ${#best} ]]; then
      best=$from best_to=$to
    fi
  done
  if [[ -n $best ]]; then print -r -- "$best_to${p#"$best"}"; else print -r -- "$p"; fi
}

# <name>, or <name>-2, -3 ... if <dir> already has one.
free_name() {
  local dir=$1 name=$2 base=${2%.*} ext=${2:e} n=1
  [[ $name == .* || $base == "$name" ]] && { base=$name ext=; }
  while [[ -e $dir/$name ]]; do (( n++ )); name=$base-$n${ext:+.$ext}; done
  print -r -- "$name"
}

# velcro.app ships velcro-sha, which reads with the cache off: without it, reading back a copy just
# written to a network share returns this Mac's cached data instead of what the server stored.
SHA_TOOL=${SELF:h:h}/MacOS/velcro-sha
[[ -x $SHA_TOOL ]] || SHA_TOOL=${SELF:h}/velcro-sha   # next to a command-line install
sha256() {
  if [[ -x $SHA_TOOL ]]; then "$SHA_TOOL" "$1"
  else /usr/bin/openssl dgst -sha256 -r <"$1" | cut -d' ' -f1
  fi
}
size_of() { stat -f %z "$1" 2>/dev/null; }
now_iso() { local t=$(date +%Y-%m-%dT%H:%M:%S%z); print -r -- "${t[1,-3]}:${t[-2,-1]}"; }
json_str() {
  local s=${1//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\t'/\\t}
  print -rn -- "\"$s\""
}

# ---------------------------------------------------------------------------------------------
# Import: copy recordings off a USB recorder, verify each copy, then clear and eject the recorder.

# Local volumes Finder shows under /Volumes (USB drives, SD cards, disk images).
local_volumes() {
  local line mp
  mount | while IFS= read -r line; do
    [[ $line == /dev/* && $line != *nobrowse* ]] || continue
    mp=${line#* on }; mp=${mp% \(*}
    [[ $mp == /Volumes/?* ]] && print -r -- "$mp"
  done
}

# "<VolumeUUID><SEP><VolumeName>" of a mounted volume.
volume_id() {
  local plist
  plist=$(diskutil info -plist "$1" 2>/dev/null) || return 1
  print -r -- "$(plutil -extract VolumeUUID raw -o - - <<<"$plist" 2>/dev/null)$SEP$(plutil -extract VolumeName raw -o - - <<<"$plist" 2>/dev/null)"
}

# Is the volume at <mount point> a registered recorder?
is_recorder() {
  local id r uuid name
  id=$(volume_id "$1") || return 1
  uuid=${id%%$SEP*} name=${id#*$SEP}
  for r in "${(@f)$(setting_all recorder)}"; do
    case $r in
      uuid:*) r=${r#uuid:}; [[ -n $uuid && ${r%% *} == "$uuid" ]] && return 0 ;;
      name:*) [[ ${r#name:} == "$name" ]] && return 0 ;;
    esac
  done
  return 1
}

# Progress for the menu bar: phase, file i of n, file name, bytes done, bytes total.
typeset -gi P_I P_N P_DONE P_TOTAL
P_NAME=
progress() {
  print -r -- "$1$SEP$P_I$SEP$P_N$SEP$P_NAME$SEP$(( P_DONE + ${2:-0} ))$SEP$P_TOTAL" >"$IMPORT_STATUS"
}

# Copy <src> into <dir> without overwriting anything, read the copy back, and compare size and
# SHA-256. <want> is the hash <src> should have, when known. Sets got_name (its name in <dir>),
# got_sha, and got_new (empty when an identical file was already there). 0 only when verified.
put_verified() {
  local src=$1 dir=$2 want=$3 name=${1:t} size part sum pid have base ext n=1
  got_name= got_sha= got_new=
  size=$(size_of "$src") || return 1
  # Check numbered conflicts too, so retrying an upload reuses its verified copy.
  base=${name%.*} ext=${name:e}
  [[ $name == .* || $base == "$name" ]] && { base=$name ext=; }
  while [[ -e $dir/$name ]]; do
    # The file itself (a recording already in the destination): never its own verified copy, so never deleted.
    [[ $dir/$name -ef $src ]] && { log "import: $src is in $dir already; not imported"; return 1; }
    if [[ $(size_of "$dir/$name") == $size ]]; then
      [[ -n $want ]] || { progress hashing; want=$(sha256 "$src") } || return 1
      progress verifying $size
      if [[ $(sha256 "$dir/$name") == "$want" ]]; then got_name=$name got_sha=$want; return 0; fi
    fi
    (( n++ )); name=$base-$n${ext:+.$ext}
  done
  # Hidden until verified, so nothing downstream picks up half a file.
  part=$dir/.$name.velcro-part sum=$(mktemp -t velcro) || return 1
  rm -f "$part"
  { (umask 077; tee -- "$part") <"$src" | /usr/bin/openssl dgst -sha256 -r >"$sum" } 2>>"$LOG" &
  pid=$!
  while kill -0 $pid 2>/dev/null; do progress copying $(size_of "$part"); zselect -t 50; done
  wait $pid || { log "import: copy failed: $src → $dir"; rm -f "$part" "$sum"; return 1; }
  got_sha=$(<"$sum"); got_sha=${got_sha%% *}; rm -f "$sum"
  if [[ -n $want && $got_sha != "$want" ]]; then
    log "import: $src doesn't match its recorded SHA-256"; rm -f "$part"; return 1
  fi
  progress verifying $size
  # Read back from the destination, not from what we just sent.
  have=$(sha256 "$part")
  if [[ $(size_of "$part") != $size || $have != "$got_sha" ]]; then
    log "import: copy of $src differs (size $(size_of "$part")/$size, sha256 $have/$got_sha)"
    rm -f "$part"; return 1
  fi
  touch -r "$src" "$part"
  mv -n "$part" "$dir/$name" && [[ ! -e $part && $(size_of "$dir/$name") == $size ]] ||
    { log "import: couldn't rename $part"; return 1; }
  got_name=$name got_new=1
}

# Append one line to <dir>/.velcro-import.jsonl, unless that file is already listed with this hash.
# <import> is the same for every file of one import run, so velcro recordings can tell when all are done.
manifest_add() {
  local dir=$1 name=$2 orig=$3 size=$4 sha=$5 at=$6 device=$7 uuid=$8 secs=$9 imp=${10}
  if [[ -f $dir/$MANIFEST ]] && grep -qF "\"file\":$(json_str $name),\"size\":$size,\"sha256\":\"$sha\"" "$dir/$MANIFEST"; then
    [[ -n ${11} ]] && crm_enqueue "$dir/$name" "$sha" "$at" "$secs" "${11}"
    return 0
  fi
  print -r -- "{\"file\":$(json_str $name),\"size\":$size,\"sha256\":\"$sha\",\"imported_at\":\"$at\",\"device\":$(json_str $device),\"device_uuid\":$(json_str $uuid),\"original\":$(json_str $orig),\"duration_s\":${secs:-null}${imp:+,\"import\":$(json_str $imp)}}" >>"$dir/$MANIFEST" || return 1
  crm_enqueue "$dir/$name" "$sha" "$at" "$secs" "${11}"
}

# Local bookkeeping only. A CRM error must not change import/delete decisions.
crm_enqueue() {
  if [[ -n $(setting crm.url) ]]; then
    local -a crm_args=(enqueue "$1" --sha256 "$2" --at "$3")
    [[ -n $4 ]] && crm_args+=(--duration "$4")
    [[ -n $5 ]] && crm_args+=(--previous "$5")
    cmd_crm "${crm_args[@]}" >/dev/null 2>&1 || log "crm: couldn't queue an imported recording; original import is recorded"
  fi
  return 0
}

# Seconds of audio, from the file header (afinfo reads WAV, MP3, M4A, AAC, FLAC ...).
duration() { afinfo "$1" 2>/dev/null | awk '/estimated duration/ { printf "%.1f", $3; exit }'; }

# Import every recording on the recorder at <mount point>.
import_volume() {
  setopt local_options extended_glob
  local mp=$1 id uuid device pat
  local -a files exts
  id=$(volume_id "$mp"); uuid=${id%%$SEP*} device=${id#*$SEP}; device=${device:-${mp:t}}
  exts=(${(s: :)${(L)$(setting import.ext)//[^a-z0-9 ]/}})
  (( ${#exts} )) || { notify "No file types to import (velcro set import.ext wav mp3)"; return 1; }
  # Hidden files and folders stay out: ._ files on FAT drives, .Trashes, .Spotlight-V100.
  pat="(#i)[^.]*.(${(j:|:)exts})(N.)"
  # Only the recorder's recordings folder when one is set, so music or other files elsewhere on
  # the drive are never taken (or deleted).
  local root=$mp sub=${$(setting import.folder)%/}
  if [[ -n $sub ]]; then
    root=$mp/$sub
    [[ -d $root ]] || { log "import: no $sub folder on $device"; return 0; }
  fi
  files=("$root"/**/${~pat})
  if (( ! ${#files} )); then
    # A folder macOS won't let us read globs to nothing too: say so instead of "nothing to import".
    if ! /bin/ls -- "$root" >/dev/null 2>&1; then
      unreadable_recorder "$mp" "$device"
      return 1
    fi
    log "import: nothing to import on $device${sub:+ in $sub}"
    return 0
  fi

  import_recordings "$device" "$uuid" "$mp" "${files[@]}"
}

# macOS lets only an app the user allowed read a removable drive. The import agent runs zsh, which
# can never be allowed, so it sees the recorder's folder but can't list it. Log it every time and
# notify once per plug-in (the agent looks again every few minutes; cmd_import forgets unplugged drives).
UNREADABLE_SEEN=$SUPPORT/unreadable.seen
unreadable_recorder() {
  local mp=$1 device=$2 key
  log "import: can't read $device: macOS doesn't let this background task read removable drives"
  key=$(volume_id "$mp") || return 0
  [[ -f $UNREADABLE_SEEN ]] && grep -qxF -- "$key" "$UNREADABLE_SEEN" && return 0
  print -r -- "$key" >>"$UNREADABLE_SEEN"
  notify "Can't import from $device in the background: macOS lets only velcro.app read removable drives. Open velcro.app (it imports while it runs), then plug in the recorder again."
}

# Selected files use the recorder's copy, queue and manifest path, but keep their originals.
import_files() {
  local f
  local -a exts=(${(s: :)${(L)$(setting import.ext)//[^a-z0-9 ]/}}) files
  (( $# )) || { notify "Choose recordings: velcro import --files <file ...>"; return 2; }
  for f in "$@"; do
    [[ -f $f && -r $f ]] || { notify "Can't read recording: $f"; return 2; }
    (( ${exts[(Ie)${(L)f:e}]} )) || { notify "Not a configured recording file type: $f"; return 2; }
    files+=("${f:A}")
  done
  import_recordings "Manual upload" "" "" "${files[@]}"
}

# An empty mount point means manual upload: never delete or eject the source.
import_recordings() {
  local device=$1 uuid=$2 mp=$3
  shift 3
  local -a files=("$@") ok failed
  local dest dir f secs imp
  local -F total_secs=0
  # One id for this whole run: the time plus random digits, so two Macs importing at once differ.
  imp=$(date +%Y%m%dT%H%M%S)-$(/usr/bin/openssl rand -hex 4) || return 1

  dest=$(folder_setting import.dest)
  [[ -n $dest ]] || { notify "Choose where recordings go first (velcro set import.dest <folder>)"; return 1; }
  if ready_dir "$dest"; then dir=$dest
  elif [[ $dest == /Volumes/?* ]]; then
    dir=$QUEUE; mkdir -p "$QUEUE/.meta"
    local alt; alt=$(renamed_mount "$dest") &&
      notify "Recordings are waiting on this Mac: ${dest} isn't attached, but ${alt} is. If that's the same share, set import.dest to ${dest/#${alt%-*}/$alt}"
  else notify "Can't use $dest for recordings: ${dest:h} doesn't exist"; return 1
  fi
  # A drive that holds the destination (or the queue) is never read as a recorder: the backups would be taken
  # for copies of themselves and deleted.
  if [[ -n $mp ]] && { [[ ${dest:A}/ == "${mp:A}"/* ]] || [[ ${dir:A}/ == "${mp:A}"/* ]] ||
                         [[ ${QUEUE:A}/ == "${mp:A}"/* ]]; }; then
    log "import: $device holds the recordings folder or the queue; not imported, nothing copied or deleted"
    # Once per drive: the import agent looks again every 5 minutes.
    local seen=$SUPPORT/backup-drive.seen key="$uuid$SEP$device"
    if ! { [[ -f $seen ]] && grep -qxF -- "$key" "$seen"; }; then
      mkdir -p "$SUPPORT" && print -r -- "$key" >>"$seen"
      notify "$device holds the recordings folder ($dest), so it isn't imported as a recorder. Nothing was copied or deleted."
    fi
    return 1
  fi
  log "import: ${#files} file(s) from $device → $dir"

  local keep_on_recorder= copied=0
  [[ -n $mp ]] && is_on import.delete || keep_on_recorder=1
  P_I=0 P_N=${#files} P_DONE=0 P_TOTAL=0
  for f in $files; do (( P_TOTAL += $(size_of "$f") )); done
  for f in $files; do
    (( P_I++ )); P_NAME=${f:t}
    # Files kept on the recorder come back on every plug-in. Once the manifest lists one from this
    # recorder at this size, skip it without re-reading, even if it was moved out of the folder.
    if [[ -n $mp && -n $keep_on_recorder && $dir != $QUEUE && -f $dir/$MANIFEST ]] &&
       grep -F "\"size\":$(size_of "$f")," "$dir/$MANIFEST" | grep -qF "\"device_uuid\":$(json_str $uuid),\"original\":$(json_str ${f:t})"; then
      (( P_DONE += $(size_of "$f") ))
      continue
    fi
    secs=$(duration "$f")
    if put_verified "$f" "$dir"; then
      [[ -n $got_new ]] && (( copied++ ))
      if [[ $dir == $QUEUE ]]; then
        # Remember what manifest_add needs for when the file moves on to import.dest.
        { [[ -e $QUEUE/.meta/$got_name ]] ||
          print -r -- "${f:t}$SEP$(size_of "$f")$SEP$got_sha$SEP$(now_iso)$SEP$device$SEP$uuid$SEP$secs$SEP$imp" >"$QUEUE/.meta/$got_name"; } &&
          crm_enqueue "$dir/$got_name" "$got_sha" "$(now_iso)" "$secs"
      else
        manifest_add "$dir" "$got_name" "${f:t}" "$(size_of "$f")" "$got_sha" "$(now_iso)" "$device" "$uuid" "$secs" "$imp"
      fi
      if (( $? )); then
        failed+=("$f")
        continue
      fi
      ok+=("$f"); (( total_secs += ${secs:-0} ))
      [[ -n $got_new ]] && log "import: $f → $dir/$got_name ($got_sha)" || log "import: $f already in $dir"
    else
      failed+=("$f")
    fi
    (( P_DONE += $(size_of "$f") ))
  done

  if (( ${#failed} )); then
    notify "$device: ${#failed} of ${#files} recordings couldn't be copied, verified or registered. No originals were deleted; see the log."
    return 1
  fi
  # Nothing new and nothing to delete: leave the recorder mounted, say nothing.
  if [[ -n $keep_on_recorder ]] && (( copied == 0 )); then
    log "import: nothing new on $device"
    [[ -n $mp ]] || notify "Selected recordings are already imported"
    return 0
  fi
  if [[ -n $mp ]] && is_on import.delete; then
    for f in $ok; do rm -f -- "$f" "${f:h}/._${f:t}" || { notify "Couldn't delete $f from the recorder"; return 1; }; done
    log "import: deleted ${#ok} verified file(s) from $device"
  fi
  local length= where= minutes
  printf -v minutes %.0f $(( total_secs / 60 ))
  if (( minutes )); then length=" ($minutes min)"; elif (( total_secs )); then length=" (under a minute)"; fi
  [[ $dir == $QUEUE ]] && where=", kept on this Mac until $dest is attached"
  if [[ -n $mp ]]; then
    if diskutil eject "$mp" >/dev/null 2>>"$LOG"; then log "import: ejected $device"; else log "import: couldn't eject $mp"; fi
  fi
  local n=${#ok}; [[ -n $keep_on_recorder ]] && n=$copied
  notify "Imported $n recording$([[ $n == 1 ]] || print s)$length$where"
}

# Move recordings kept on this Mac to import.dest, verifying each before deleting it here.
flush_queue() {
  local dest=$(folder_setting import.dest) f meta orig size sha at device uuid secs imp
  local -a files moved failed
  files=("$QUEUE"/[^.]*(N.))
  (( ${#files} )) && ready_dir "$dest" || return 0
  P_I=0 P_N=${#files} P_DONE=0 P_TOTAL=0
  for f in $files; do (( P_TOTAL += $(size_of "$f") )); done
  for f in $files; do
    (( P_I++ )); P_NAME=${f:t}
    orig=${f:t} size= sha= at=$(now_iso) device= uuid= secs= imp=
    meta=$QUEUE/.meta/${f:t}
    [[ -r $meta ]] && IFS=$SEP read -r orig size sha at device uuid secs imp <"$meta"
    if put_verified "$f" "$dest" "$sha" &&
       manifest_add "$dest" "$got_name" "$orig" "$(size_of "$f")" "$got_sha" "$at" "$device" "$uuid" "$secs" "$imp" "$f"; then
      log "import: moved $f → $dest/$got_name"
      rm -f -- "$f" "$meta"; moved+=("$f")
    else
      failed+=("$f")
    fi
    (( P_DONE += ${size:-0} ))
  done
  (( ${#moved} )) && notify "Moved ${#moved} recording$([[ ${#moved} == 1 ]] || print s) from this Mac to $dest"
  (( ${#failed} )) && notify "${#failed} recording(s) are still on this Mac: they couldn't be copied to $dest or didn't verify. See the log."
  return 0
}

# A drive with the recorder's recordings folder that isn't a registered recorder: say so once per drive,
# so a recorder whose volume ID or name changed (reformatted, replaced) isn't skipped without a word.
note_unregistered() {
  local mp=$1 sub=${$(setting import.folder)%/} id seen=$SUPPORT/unregistered.seen
  [[ -n $sub && -d $mp/$sub ]] || return 0
  id=$(volume_id "$mp") || return 0
  [[ -f $seen ]] && grep -qxF -- "$id" "$seen" && return 0
  print -r -- "$id" >>"$seen"
  notify "${mp:t} has a $sub folder but isn't a registered recorder, so nothing was imported. If it's your recorder: velcro recorder add \"$mp\""
}

# velcro import [--auto] [mount point ...] | --files <file ...>
cmd_import() {
  local auto= selected= mp round result=0
  [[ $1 == --auto ]] && { auto=1; shift; }
  [[ $1 == --files ]] && { selected=1; shift; }
  if [[ -n $selected ]]; then
    [[ -z $auto && $# -gt 0 ]] || { notify "Usage: velcro import --files <file ...>"; return 2; }
  fi
  # Quick exit for the LaunchAgent, which runs this on every mount of anything.
  local -a waiting=("$QUEUE"/[^.]*(N.[1]))
  [[ -n $auto && $# == 0 && -z $(setting recorder) && ${#waiting} == 0 ]] && return 0
  # While velcro.app runs, it imports: macOS lets it read the recorder, and never the agent's zsh.
  # Taking the lock here would leave the app waiting on an import that can't see any recordings.
  [[ -n $auto && $# == 0 && -z $VELCRO_APP ]] && pgrep -qf -U $UID "$APP_EXEC" && return 0
  mkdir -p "$CONF_DIR" "$SUPPORT" "${LOG:h}"
  # One import at a time, separate from the reconnect lock so shares keep coming back meanwhile.
  # A second request (say, the NAS remounting mid-import) asks the running one to look again.
  local lockfd
  : >>"$IMPORT_LOCK"
  if ! zsystem flock -t 0 -f lockfd "$IMPORT_LOCK" 2>/dev/null; then
    if [[ -n $selected ]]; then
      notify "An import is already running. Try uploading these recordings again when it finishes."
      return 1
    fi
    : >"$IMPORT_AGAIN"
    print "an import is already running"
    return 0
  fi
  trap 'rm -f "$IMPORT_STATUS"' EXIT
  local -a vols
  for round in 1 2 3; do
    rm -f "$IMPORT_AGAIN"
    if [[ -n $selected ]]; then
      import_files "$@" || result=$?
      # A mount during the upload requests a recorder scan, not another upload.
      selected=; auto=1; set --
    elif [[ -z $auto ]] || is_on import.auto; then
      if (( $# )); then vols=("$@"); else vols=("${(@f)$(local_volumes)}"); fi
      # A drive that was unplugged gets its "can't read" notification again when it comes back.
      if (( ! $# )) && [[ -s $UNREADABLE_SEEN ]]; then
        local -a mounted_ids=()
        for mp in $vols; do [[ -n $mp ]] && mounted_ids+=("$(volume_id "$mp")"); done
        print -rl -- ${(@)${(f)"$(<$UNREADABLE_SEEN)"}:*mounted_ids} >"$UNREADABLE_SEEN"
      fi
      for mp in $vols; do
        [[ -n $mp ]] || continue
        if is_recorder "$mp"; then
          import_volume "$mp"
        elif (( $# )); then
          print -u2 "not a registered recorder: $mp  (velcro recorder add \"$mp\")"
        else
          note_unregistered "$mp"
        fi
      done
    fi
    flush_queue
    [[ -e $IMPORT_AGAIN ]] || break
  done
  rm -f "$IMPORT_STATUS"
  return $result
}

# velcro recorder [add [mount point] | rm <uuid|name> | candidates]
cmd_recorder() {
  local mp id uuid name r
  local -a regs
  regs=("${(@f)$(setting_all recorder)}"); [[ -z ${regs[1]} ]] && regs=()
  case $1 in
    ""|list)
      (( ${#regs} )) || { print "no recorders yet  →  plug one in, then: velcro recorder add"; return 0; }
      for r in $regs; do print -r -- "  $r"; done
      local mounted=("${(@f)$(local_volumes)}")
      for mp in $mounted; do [[ -n $mp ]] && is_recorder "$mp" && print -r -- "plugged in: $mp"; done
      ;;
    candidates)
      # For velcro.app: local volumes that aren't recorders yet, as "mount point SEP uuid SEP name".
      local_volumes | while IFS= read -r mp; do
        is_recorder "$mp" && continue
        id=$(volume_id "$mp") || continue
        print -r -- "$mp$SEP$id"
      done
      ;;
    add)
      mp=$2
      if [[ -z $mp ]]; then
        local -a c=("${(@f)$(cmd_recorder candidates)}"); [[ -z ${c[1]} ]] && c=()
        (( ${#c} == 1 )) || {
          print -u2 "plug in the recorder, then name its volume: velcro recorder add /Volumes/<name>"
          (( ${#c} )) && print -rlu2 -- "  "${^c%%$SEP*}
          return 2
        }
        mp=${c[1]%%$SEP*}
      fi
      [[ ${${(f)"$(local_volumes)"}[(Ie)${mp%/}]} -gt 0 ]] || { print -u2 "not a mounted drive: $mp"; return 2; }
      is_recorder "$mp" && { print "already a recorder: $mp"; return 0; }
      id=$(volume_id "$mp") || return 1
      uuid=${id%%$SEP*} name=${id#*$SEP}
      if [[ -n $uuid ]]; then r="uuid:$uuid ${name:-${mp:t}}"; else r="name:${name:-${mp:t}}"; fi
      cmd_set recorder $regs "$r" && print -r -- "recorder added: $r"
      if [[ -z $(setting import.dest) ]]; then
        print "next, in this order (importing starts once import.dest is set):"
        print "  velcro set import.folder RECORD   if recordings live in one folder (leaves music etc. alone)"
        print "  velcro set import.delete no       to keep recordings on the recorder too"
        print "  velcro set import.dest <folder>   ~/Recordings, or a folder on a drive or share"
      fi
      ;;
    rm|remove)
      [[ -n $2 ]] || { print -u2 "usage: velcro recorder rm <uuid or name>"; return 2; }
      local -a kept
      for r in $regs; do
        [[ $r == ("uuid:$2"(| *)|"name:$2"|uuid:*" $2") ]] || kept+=("$r")
      done
      (( ${#kept} < ${#regs} )) || { print -u2 "no recorder matches: $2"; return 1; }
      cmd_set recorder $kept && print "recorder removed: $2"
      ;;
    *) print -u2 "usage: velcro recorder [add [/Volumes/<name>] | rm <uuid|name>]"; return 2 ;;
  esac
}

# ---------------------------------------------------------------------------------------------
# Send: put a file in an inbox folder (on a share, or one Dropbox or Syncthing keeps in sync) and
# copy the path another machine sees it at.

# Delete inbox items older than send.keep days.
# Delete what velcro itself sent more than send.keep days ago, and only if it's unchanged since
# (same size and modification time). Anything else in the folder is never touched: it may be a
# Dropbox or shared folder with the user's own files, including one that reuses a sent file's name.
clean_inbox() {
  local inbox=$1 keep=$(setting send.keep) line at f stamp n=0
  [[ $keep == <1-> && -s $SENT ]] || return 0
  local -a kept fields
  for line in "${(@f)$(<$SENT)}"; do
    fields=("${(@ps:$SEP:)line}")
    at=${fields[1]} f=${fields[2]} stamp=${fields[3]}
    if [[ $at == <-> && -n $stamp ]] && (( EPOCHSECONDS - at > keep * 86400 )) && [[ $f == "$inbox"/?* ]]; then
      if [[ -e $f && $(stat -f '%z:%m' "$f" 2>/dev/null) == "$stamp" ]]; then
        rm -rf -- "$f" 2>>"$LOG" && (( n++ ))
      elif [[ -e $f ]]; then
        log "send: left $f alone: it changed after velcro sent it"
      fi
    elif [[ -n $stamp ]]; then
      kept+=("$line")
    fi
  done
  if (( ${#kept} )); then print -rl -- "${kept[@]}" >"$SENT.tmp"; else : >"$SENT.tmp"; fi
  mv -f "$SENT.tmp" "$SENT"
  (( n )) && log "send: cleaned $n item(s) velcro sent more than $keep days ago from $inbox"
  return 0
}

# The clipboard as a file: a copied file itself, or a copied image saved as PNG.
clipboard_file() {
  local f
  f=$(osascript -e 'POSIX path of (the clipboard as «class furl»)' 2>/dev/null) && [[ -e $f ]] &&
    { print -r -- "$f"; return 0; }
  mkdir -p "$CLIPS"
  f=$CLIPS/clip-$(date +%Y%m%d-%H%M%S).png
  osascript -e 'on run argv' -e 'set f to open for access (POSIX file (item 1 of argv)) with write permission' \
    -e 'try' -e 'write (the clipboard as «class PNGf») to f' -e 'on error m' -e 'close access f' -e 'error m' \
    -e 'end try' -e 'close access f' -e 'end run' "$f" >/dev/null 2>&1 && [[ -s $f ]] &&
    { print -r -- "$f"; return 0; }
  rm -f "$f"
  return 1
}

# velcro send [file ...]   with no file, sends what's on the clipboard. --clean only cleans.
cmd_send() {
  local inbox=$(folder_setting send.inbox) f name
  local -a remote
  mkdir -p "${LOG:h}" "$SUPPORT"
  [[ -n $inbox ]] || { notify "Choose an inbox folder first: velcro set send.inbox <folder>"; return 2; }
  ready_dir "$inbox" || { notify "The inbox isn't available: $inbox (not attached, or its parent folder doesn't exist)"; return 1; }
  clean_inbox "$inbox"
  [[ $1 == --clean ]] && return 0
  if (( ! $# )); then
    f=$(clipboard_file) || { notify "Nothing to send: copy a file or an image first"; return 1; }
    set -- "$f"
  fi
  for f in "$@"; do
    [[ -e $f ]] || { notify "No such file: $f"; return 1; }
    f=${f:A}
    name=$(free_name "$inbox" "${f:t}")
    # -X: no extended attributes, so the share doesn't collect ._ files.
    cp -RX -- "$f" "$inbox/.$name.velcro-part" 2>>"$LOG" && mv -n "$inbox/.$name.velcro-part" "$inbox/$name" ||
      { rm -rf "$inbox/.$name.velcro-part"; notify "Couldn't copy $f to $inbox"; return 1; }
    log "send: $f → $inbox/$name"
    # Size and modification time too: cleaning deletes it only if it's still exactly what was sent.
    print -r -- "$EPOCHSECONDS$SEP$inbox/$name$SEP$(stat -f '%z:%m' "$inbox/$name")" >>"$SENT"
    remote+=("$(remote_path "$inbox/$name")")
  done
  print -rl -- $remote
  # Only because this was asked for: the paths replace whatever was on the clipboard.
  print -rn -- "${(F)remote}" | pbcopy
}

# ---------------------------------------------------------------------------------------------
# Mac mic: lend this Mac's microphone to a Linux machine you use over SSH, so voice input there
# (Claude Code's /voice, for one) hears you. velcro-mic-host on that machine adds a PipeWire
# microphone and says when something starts and stops recording from it; velcro.app opens the
# Mac's microphone only in between. The app does the sending; these commands set it up.

mic_ssh() { ssh -o BatchMode=yes -o ConnectTimeout=10 "$@"; }

# velcro mic setup <host>: put velcro-mic-host on the machine and check it can run there.
mic_setup() {
  local host=$1 out
  [[ -n $host && $host != -* && $host != *[[:space:]]* ]] || { print -u2 "usage: velcro mic setup <ssh host>"; return 2; }
  [[ -r $MIC_HELPER ]] || { print -u2 "Mac mic needs velcro.app: curl -fsSL https://velcro.dgit.co/install | sh"; return 1; }
  print "installing velcro-mic-host on $host …"
  out=$(mic_ssh "$host" 'mkdir -p ~/.local/bin && cat >~/.local/bin/.velcro-mic-host.new &&
    chmod +x ~/.local/bin/.velcro-mic-host.new && mv -f ~/.local/bin/.velcro-mic-host.new ~/.local/bin/velcro-mic-host &&
    ~/.local/bin/velcro-mic-host --check' <"$MIC_HELPER" 2>&1)
  local rc=$?
  print -r -- "$out"
  if (( rc )); then
    [[ $out == *"Permission denied"* || $out == *"Could not resolve"* || $out == *"timed out"* ]] &&
      print -u2 "velcro needs 'ssh $host' to work without a password prompt (an SSH key)."
    return 1
  fi
  cmd_set mic.host "$host" && log "mic: set up on $host"
}

cmd_mic() {
  local host=$(setting mic.host)
  case ${1:-status} in
    setup) mic_setup "$2" ;;
    on)
      if [[ -n $2 ]]; then mic_setup "$2" || return; host=$2
      elif [[ -z $host ]]; then print -u2 "Which machine? velcro mic on <ssh host>"; return 2
      fi
      cmd_set mic.on yes || return
      : >"$MIC_TAKE"
      print "Mac mic on for $host: the microphone opens only while something there records."
      pgrep -xq velcro || print "velcro.app sends it; open velcro.app (it isn't running)."
      ;;
    off) cmd_set mic.on no && print "Mac mic off" ;;
    remove)
      [[ -n $host ]] || { print "Mac mic isn't set up"; return 0; }
      cmd_set mic.on no
      mic_ssh "$host" 'rm -f ~/.local/bin/velcro-mic-host' && print "removed velcro-mic-host from $host"
      cmd_set mic.host
      ;;
    status)
      if [[ -z $host ]]; then print "Mac mic: not set up (velcro mic setup <ssh host>)"; return 0; fi
      local s=; [[ -r $MIC_STATUS ]] && s=$(<"$MIC_STATUS")
      if is_on mic.on; then
        pgrep -xq velcro || s="velcro.app isn't running"
        print -r -- "Mac mic → $host: on${s:+ ($s)}"
      else
        print -r -- "Mac mic → $host: off"
      fi
      ;;
    *) print -u2 "usage: velcro mic [status | setup <host> | on [<host>] | off | remove]"; return 2 ;;
  esac
}

# ---------------------------------------------------------------------------------------------
# Mac clipboard: images copied on this Mac (a screenshot, an image copied in an app or Finder)
# paste with ⌃V in Claude Code on a Linux machine you use over SSH. velcro.app sends each one to
# velcro-clip-host there as it's copied; nothing else on the clipboard leaves the Mac.

# velcro clip setup <host>: put velcro-clip-host on the machine and link xclip and wl-paste to it.
clip_setup() {
  local host=$1 out
  [[ -n $host && $host != -* && $host != *[[:space:]]* ]] || { print -u2 "usage: velcro clip setup <ssh host>"; return 2; }
  [[ -r $CLIP_HELPER ]] || { print -u2 "Mac clipboard needs velcro.app: curl -fsSL https://velcro.dgit.co/install | sh"; return 1; }
  print "installing velcro-clip-host on $host …"
  out=$(mic_ssh "$host" 'mkdir -p ~/.local/bin && cat >~/.local/bin/.velcro-clip-host.new &&
    chmod +x ~/.local/bin/.velcro-clip-host.new && mv -f ~/.local/bin/.velcro-clip-host.new ~/.local/bin/velcro-clip-host &&
    ~/.local/bin/velcro-clip-host install' <"$CLIP_HELPER" 2>&1)
  local rc=$?
  print -r -- "$out"
  if (( rc )); then
    [[ $out == *"Permission denied"* || $out == *"Could not resolve"* || $out == *"timed out"* ]] &&
      print -u2 "velcro needs 'ssh $host' to work without a password prompt (an SSH key)."
    return 1
  fi
  cmd_set clip.host "$host" && log "clip: set up on $host"
}

cmd_clip() {
  local host=$(setting clip.host)
  case ${1:-status} in
    setup) clip_setup "$2" ;;
    on)
      if [[ -n $2 ]]; then clip_setup "$2" || return; host=$2
      elif [[ -z $host ]]; then print -u2 "Which machine? velcro clip on <ssh host>"; return 2
      fi
      cmd_set clip.on yes || return
      print "Mac clipboard on for $host: images you copy here paste with ⌃V in Claude Code there. Text stays on this Mac."
      pgrep -xq velcro || print "velcro.app sends them; open velcro.app (it isn't running)."
      ;;
    off) cmd_set clip.on no && print "Mac clipboard off" ;;
    remove)
      [[ -n $host ]] || { print "Mac clipboard isn't set up"; return 0; }
      cmd_set clip.on no
      mic_ssh "$host" '~/.local/bin/velcro-clip-host remove && rm -f ~/.local/bin/velcro-clip-host' >/dev/null ||
        { print -u2 "couldn't reach $host to remove velcro-clip-host; try again when it's up"; return 1; }
      print "removed velcro-clip-host (and its xclip and wl-paste) from $host"
      cmd_set clip.host
      ;;
    status)
      if [[ -z $host ]]; then print "Mac clipboard: not set up (velcro clip on <ssh host>)"; return 0; fi
      local s=; [[ -r $CLIP_STATUS ]] && s=$(<"$CLIP_STATUS")
      if is_on clip.on; then
        pgrep -xq velcro || s="velcro.app isn't running"
        print -r -- "Mac clipboard → $host: on${s:+ ($s)}"
      else
        print -r -- "Mac clipboard → $host: off"
      fi
      ;;
    *) print -u2 "usage: velcro clip [status | setup <host> | on [<host>] | off | remove]"; return 2 ;;
  esac
}

# Transcribe: an audio file (a voice memo from Slack, a recording) to text through the speech-to-text
# Claude Code's /voice uses, or ChatGPT's that Codex uses, with the user's own login to each (whichever is
# signed in, in transcribe.engine order). velcro-transcribe does the talking; this adds the settings, the
# optional fallback command, and what velcro.app needs. Logins are never renewed or printed, and each goes
# only to its own company. Neither is a public API: own login, own audio.

# python3, without setting off the "install the command line developer tools" dialog on a Mac.
transcribe_python() {
  local py=${commands[python3]}
  [[ -n $py ]] && { [[ $OSTYPE != darwin* || $py != /usr/bin/python3 ]] || xcode-select -p >/dev/null 2>&1; } &&
    { print -r -- "$py"; return 0; }
  [[ $OSTYPE == darwin* ]] && print -u2 "velcro transcribe needs python3: xcode-select --install" ||
    print -u2 "velcro transcribe needs python3"
  return 1
}

# velcro transcribe [--lang <code>] [--engine claude|codex] [--no-fallback] [--json] <file>: the text on stdout.
cmd_transcribe() {
  local lang=$(setting transcribe.lang) fallback=$(setting transcribe.fallback) json=
  local -a engines=(${=$(setting_all transcribe.engine)})
  while [[ $1 == -* ]]; do
    case $1 in
      --lang) [[ $2 == [a-z][a-z](|-[A-Za-z][A-Za-z]) ]] || { print -u2 -- "--lang takes a language code, like en or ko"; return 2; }
              lang=$2; shift 2 ;;
      --engine) [[ $2 == (claude|codex) ]] || { print -u2 -- "--engine is claude or codex"; return 2; }
                engines=($2); shift 2 ;;
      --no-fallback) fallback=; shift ;;
      --json) json=1; shift ;;
      --) shift; break ;;
      *) print -u2 "unknown option: $1"; return 2 ;;
    esac
  done
  (( $# == 1 )) || { print -u2 "usage: velcro transcribe [--lang <code>] [--engine claude|codex] [--no-fallback] [--json] <audio file>"; return 2; }
  local file=${1:a}
  [[ -f $file ]] || { print -u2 "no such file: $1"; return 1; }
  [[ -r $TRANSCRIBE_HELPER ]] || { print -u2 "velcro-transcribe is missing next to $SELF; install velcro again"; return 1; }
  local -a args=(--lang "$lang" --engine "${(j:,:)${(@u)engines}}") cmd  # (u): set --add can repeat one
  local login py text= engine= rc codex=$(folder_setting transcribe.codex)
  for login in "${(@f)$(setting_all transcribe.login)}"; do
    [[ -n $login ]] && args+=(--login "${login/#\~/$HOME}")
  done
  [[ -n $codex ]] && args+=(--codex "$codex")
  # The helper's messages go to the terminal afterwards; velcro.app gets a notification instead.
  # A folder of its own (mktemp -d, 0700): a name next to a temp file in /tmp is one anyone could take first.
  local tmp
  tmp=$(mktemp -d "${TMPDIR:-/tmp}/velcro-transcribe.XXXXXX") || { print -u2 "couldn't make a temporary folder"; return 1; }
  local err=$tmp/messages
  : >"$err"
  if py=$(transcribe_python 2>>"$err"); then
    text=$("$py" "$TRANSCRIBE_HELPER" "${args[@]}" --engine-file "$tmp/engine" -- "$file" 2>>"$err"); rc=$?
    (( rc )) || { [[ -s $tmp/engine ]] && engine=$(<"$tmp/engine") || engine=${engines[1]}; }
  else
    rc=1
  fi
  if (( rc )) && [[ -n $fallback ]]; then
    # The setting is a command line; the file goes last. A leading ~ means the home folder.
    cmd=("${(Q@)${(z)fallback}}"); cmd[1]=${cmd[1]/#\~/$HOME}
    print -r -- "trying the fallback: $fallback" >>"$err"
    engine=fallback
    text=$("${cmd[@]}" "$file" 2>>"$err"); rc=$?
    (( rc )) || [[ -n ${text//[[:space:]]/} ]] || { print -r -- "the fallback printed nothing" >>"$err"; rc=1; }
  fi
  local why=$(tail -n 1 "$err" 2>/dev/null)
  [[ -z $VELCRO_APP ]] && cat "$err" >&2
  rm -rf -- "${tmp:?}"
  if (( rc )); then
    [[ -n $VELCRO_APP ]] && notify "Couldn't transcribe ${file:t}: ${why#velcro-transcribe: }"
    return 1
  fi
  if [[ -n $json ]]; then
    print -r -- "{\"engine\":$(json_str $engine),\"file\":$(json_str "$file"),\"text\":$(json_str "$text")}"
  else
    print -r -- "$text"
  fi
}

# Recordings: what import brought in, transcribed (engine), tidied and delivered (hook commands), one
# file at a time, with failures retried later. velcro-recordings does the work; this hands it the settings.
# On Linux there is no Library folder for its state, so it goes where XDG says.
cmd_recordings() {
  local sub=${1:-status}; shift 2>/dev/null
  [[ $sub == (run|status|check) ]] || { print -u2 "usage: velcro recordings [status | run | check [--require tidy|deliver|done] ...]"; return 2; }
  local -a check_args
  while (( $# )); do
    [[ $sub == check && $1 == --require && ${2:-} == (tidy|deliver|done) ]] || {
      print -u2 "usage: velcro recordings check [--require tidy|deliver|done] ..."; return 2;
    }
    check_args+=("$1" "$2"); shift 2
  done
  [[ -r $RECORDINGS_HELPER ]] || { print -u2 "velcro-recordings is missing next to $SELF; install velcro again"; return 1; }
  local dir=$(folder_setting recordings.dir)
  [[ -n $dir ]] || dir=$(folder_setting import.dest)
  [[ -n $dir ]] || { print -u2 "set recordings.dir (or import.dest) first: velcro set recordings.dir <folder>"; return 1; }
  local out=$(folder_setting recordings.out) state py
  [[ -n $out ]] || out=$dir/_velcro
  [[ $OSTYPE == darwin* ]] && state=$SUPPORT || state=${XDG_STATE_HOME:-$HOME/.local/state}/velcro
  # No default engine: long recordings through someone's Claude login should be a choice, not a default.
  [[ $sub == check || -n $(setting recordings.engine) ]] || { print -u2 "choose an engine first: velcro set recordings.engine \"<command that prints the text>\" (or claude or codex, which use your own login)"; return 1; }
  py=$(transcribe_python) || return 1
  "$py" "$RECORDINGS_HELPER" $sub --dir "$dir" --out "$out" --state "$state" --velcro "$SELF" \
    --engine "$(setting recordings.engine)" --lang "$(setting transcribe.lang)" --since "$(setting recordings.since)" --timeout "$(setting recordings.timeout)" \
    --tidy "$(setting recordings.tidy)" --deliver "$(setting recordings.deliver)" --done "$(setting recordings.done)" \
    --alert "$(setting recordings.alert)" --ext "$(setting import.ext)" "${check_args[@]}"
}

# Shared Python CRM client; settings and recording destinations stay with velcro.
cmd_crm() {
  [[ -r $CRM_HELPER ]] || { print -u2 "velcro-crm is missing; install velcro again"; return 1; }
  local py
  py=$(transcribe_python) || return 1
  "$py" "$CRM_HELPER" --url "$(setting crm.url)" "$@"
}

cmd_help() {
  cat <<EOF
velcro $VERSION — keep network shares stuck to your Mac

  velcro add smb://user@host/share [fallback-host ...]
  velcro add nfs://host/export/path [fallback-host ...]
  velcro rm <url|share>      stop tracking a share
  velcro status              what's mounted, reachable, paused
  velcro pause | resume      stop/start remounting (eject stays ejected)
  velcro install | uninstall background agent (every ${INTERVAL}s + network change);
                             run from velcro.app, links this command instead
  velcro run                 one pass now
  velcro logs [n]

  velcro import              copy recordings off a plugged-in recorder, verify, delete, eject
  velcro import --files <file ...>  upload selected recordings to import.dest; keep originals
  velcro recorder add        register the plugged-in drive as a recorder (rm <name> to forget it)
  velcro send [file ...]     put files (or the clipboard) in your inbox folder, copy its path
  velcro mic on <ssh host>   lend this Mac's microphone to a Linux machine you SSH into, for voice
                             input there (Claude Code /voice); open only while it records
  velcro mic off | status | remove
  velcro clip on <ssh host>  images you copy on this Mac paste with ⌃V in Claude Code on a Linux
                             machine you SSH into; text and passwords stay here
  velcro clip off | status | remove
  velcro transcribe <file>   audio file to text, through the speech-to-text Claude Code's /voice
                             uses or ChatGPT's (Codex), with your own login (--lang ko, --json,
                             --engine codex, --no-fallback). Not a public API: only your own login
                             and audio, never shared
  velcro recordings [run]    what is waiting in recordings.dir; run transcribes, tidies and
                             delivers it (engine and hook commands below), retrying failures
  velcro recordings check [--require tidy|deliver|done] ...
                             read-only JSON reconciliation; exit 1 for gaps or missing hooks
  velcro crm connect [--name <device>]  approve file uploads in the browser
  velcro crm disconnect      forget this connection; revoke it in the dashboard
  velcro crm leads [--json]  list recent cards (or paste a card address)
  velcro crm send <file> --lead <id or card URL>  upload only to the chosen card
  velcro crm pending         show recordings waiting for a person's card selection
  velcro settings            show settings;  velcro set [--add] <key> [value ...] to change one

settings ($SETTINGS):
  crm.url       CRM server origin, no default (e.g. https://api.example.com)
  crm.approve   CRM approval page, no default (e.g. https://auth.example.com/device/)
  import.dest   folder for recordings: ~/Recordings, /Volumes/home/recordings, iCloud Drive ...
  import.ext    file types, default "${DEFAULTS[import.ext]}"
  import.folder only this folder on the recorder, e.g. RECORD (default: the whole drive)
  import.delete delete from the recorder once every copy verifies (yes)
  import.auto   import as soon as a recorder is plugged in (yes)
  send.inbox    folder for sent files: /Volumes/home/inbox, a Dropbox folder ...
  send.keep     days to keep inbox files (${DEFAULTS[send.keep]}; 0 keeps them)
  map           path on another machine, e.g. "/Volumes/home -> /mnt/nas/me". Several rules:
                velcro set map "<a> -> <b>" "<c> -> <d>", or velcro set --add map "<c> -> <d>"
  mic.host      the SSH host that gets the microphone (velcro mic setup <host> sets it)
  mic.on        yes while lending it (no)
  mic.warm      seconds the microphone stays open after a recording, so the next one starts
                at once (0)
  clip.host     the SSH host that gets copied images (velcro clip setup <host> sets it)
  clip.on       yes while sending them (no)
  transcribe.lang      language spoken in the files, e.g. ko (${DEFAULTS[transcribe.lang]})
  transcribe.engine    engines to try in order, each only if you're signed in to it
                       (${DEFAULTS[transcribe.engine]}): claude is Claude Code's, codex ChatGPT's with
                       Codex's ChatGPT login (it keeps the audio 30 days). Just one: velcro set
                       transcribe.engine claude
  transcribe.login     Claude Code folder whose login to use; repeat to try several
                       (\$CLAUDE_CONFIG_DIR or ~/.claude). It is read, never renewed
  transcribe.codex     Codex folder whose ChatGPT login to use (\$CODEX_HOME or ~/.codex). Read,
                       never renewed; an API key is never used
  transcribe.fallback  command to run with the file when no engine can transcribe it, printing
                       the text, e.g. "whisper-cli -nt -f" (none: the failure is reported)
  recordings.dir       folder velcro import fills, with .velcro-import.jsonl (import.dest)
  recordings.out       where transcripts and hook output go (<recordings.dir>/_velcro)
  recordings.engine    a command line that prints the text: {file} is the recording (else it goes
                       last), {json} a file it may write; or claude or codex (velcro transcribe
                       with that engine, your login).
                       No default: velcro recordings waits until one is chosen
  recordings.tidy      command run after transcribing, with VELCRO_RECORDING, VELCRO_TRANSCRIPT,
                       VELCRO_TRANSCRIPT_JSON, VELCRO_RECORD and VELCRO_OUT; its output is kept
  recordings.deliver   command run after tidying, the same plus VELCRO_TIDIED
                       File variables are private copies (0600, in a 0700 temp folder), removed after
                       each hook; audio is copied and hash-checked again. VELCRO_OUT is the output folder.
  recordings.done      command run once per import, when every recording it brought in is done,
                       with VELCRO_IMPORT, VELCRO_RECORDS, VELCRO_RECORDINGS, VELCRO_TRANSCRIPTS,
                       VELCRO_TRANSCRIPTS_JSON (one path per line, private copies) and VELCRO_OUT
                       (each also as <name>_FILE; inline only up to 64 KiB)
  recordings.alert     command run with one line of text when a step fails a 3rd time (once a day)
  recordings.since     only recordings imported at or after this time, e.g. 2026-10-21
  recordings.timeout   seconds the engine may take before it is stopped (1800 + half the length);
                       raise it if the engine waits for a busy GPU first

config: $CONF
EOF
}

case ${1:-help} in
  run)         cmd_run ;;
  status|st)   cmd_status ;;
  add)         shift; cmd_add "$@" ;;
  rm|remove)   shift; cmd_rm "$@" ;;
  install)     cmd_install ;;
  uninstall)   cmd_uninstall ;;
  pause)       cmd_pause ;;
  resume)      cmd_resume ;;
  logs|log)    shift; cmd_logs "$@" ;;
  scan)        shift; cmd_scan "$@" ;;
  env)         cmd_env ;;
  import)      shift; cmd_import "$@" ;;
  recorder|recorders) shift; cmd_recorder "$@" ;;
  send)        shift; cmd_send "$@" ;;
  mic)         shift; cmd_mic "$@" ;;
  clip)        shift; cmd_clip "$@" ;;
  transcribe)  shift; cmd_transcribe "$@" ;;
  recordings)  shift; cmd_recordings "$@" ;;
  crm)         shift; cmd_crm "$@" ;;
  settings)    cmd_settings ;;
  set)         shift; cmd_set "$@" ;;
  version|-v|--version) print "velcro $VERSION" ;;
  help|-h|--help) cmd_help ;;
  *) print -u2 "unknown command: $1"; cmd_help; exit 2 ;;
esac
