Skip to content

refactor: replace crates/ with zsh scripts, add zarg - #82

Merged
radiosilence merged 3 commits into
mainfrom
no-rust-bins
Aug 7, 2026
Merged

refactor: replace crates/ with zsh scripts, add zarg#82
radiosilence merged 3 commits into
mainfrom
no-rust-bins

Conversation

@radiosilence

@radiosilence radiosilence commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Deletes crates/ — 15 Rust binaries, 3097 lines, 326 transitive crates — and replaces them with 15 zsh scripts in scripts/, plus a zarg plugin that gives them all argument parsing and completions. Net -4600 lines.

Why

Ten of the fifteen binaries shelled out to ffmpeg, metaflac, aria2c, unzip, beet or exiftool anyway, so the Rust was a progress bar wrapped around someone else's program. The costs were real: git2 vendored libgit2, which is the sole reason setup-linux installed build-essential; reqwest + rustls came along for two HTTP GETs curl already does.

clean-exif was the only binary doing real in-process work and it still loses to exiftool -all= -overwrite_original, which strips XMP/IPTC/ICC as well.

Every replacement tool was already installed. The only additions are lsof and libimage-exiftool-perl on Linux, covering the two things kill-port and clean-exif used to do in-process.

task reinstall-bins had not rebuilt anything in a long time. Its guard was status: ["test -f {{.DOTFILES}}/bin/clean-dls"] — satisfied forever after the first install, so later edits to crates/ were skipped silently and the cheatsheet's "upd rebuilds rust bins" was false.

zarg: declare once, derive everything

Each script needs a parser and a compdef, and hand-written pairs drift the moment a flag is added. zsh-plugins/zarg takes one declaration and derives parsing, --help, --version and completions for three shells from it.

The whole of scripts/kill-port's interface:

zarg_init kill-port 'Kill process listening on specified port'
zarg_flag -n --dry-run 'show what would be killed without doing it'
zarg_opt  -s --signal  'signal to send' default=TERM metavar=SIGNAL \
          values='TERM KILL INT HUP QUIT USR1 USR2'
zarg_arg  port 'port number' required
zarg_go "$@"

That alone produces --help:

  Kill process listening on specified port

  usage: kill-port [options] <port>

  arguments:
    port                     port number

  options:
    -n, --dry-run            show what would be killed without doing it
    -s, --signal SIGNAL      signal to send (default: TERM)
    -h, --help               show this help
    --version                show version
    --completions SHELL      emit completions (zsh, fish, bash)

…typo correction:

$ kill-port --dry-ru 3000
kill-port: unknown option: --dry-ru
  did you mean '--dry-run'?
  try 'kill-port --help'

$ kill-port -s 9 3000
kill-port: --signal: '9' is not one of: TERM KILL INT HUP QUIT USR1 USR2

…and --completions zsh:

_arguments -s \
  '(-n --dry-run)'{-n,--dry-run}'[show what would be killed without doing it]' \
  '(-s --signal)'{-s,--signal}'[signal to send]:SIGNAL:(TERM KILL INT HUP QUIT USR1 USR2)' \
  '1:port: ' \
  ...

--completions fish:

complete -c kill-port -s n -l dry-run -d 'show what would be killed without doing it'
complete -c kill-port -s s -l signal -r -d 'signal to send' -a 'TERM KILL INT HUP QUIT USR1 USR2'

…and --completions bash:

_kill_port() {
  case "$prev" in
    -s|--signal) COMPREPLY=($(compgen -W "TERM KILL INT HUP QUIT USR1 USR2" -- "$cur")); return ;;
  esac
  ...
}
complete -F _kill_port kill-port

Parsing covers --long value, --long=value, -s value, attached shorts (-b192), clustered flags (-nk), --, defaults, env= fallbacks, required, variadic and validated values= sets.

Completion fidelity differs by shell and that's the shells' doing: complete= names a zsh function, so fish and bash fall back to file completion there. Declared value sets work in all three.

Using the scripts

Full reference with examples in scripts/README.md. A sampler:

kill-port 5001; task run             # reclaim the port, restart
kill-port -n 5432                    # what would die, without killing it

prune ~/Downloads                    # dirs under 3 MB, with a prompt
MIN_SIZE=51200 prune ~/Media         # 50 MB threshold via the environment

clean-dls -n ~/Downloads/album       # always look first
vimv *.jpg                           # batch rename in $EDITOR, git mv aware

to-audio flac ~/Music/wavs           # wav/aiff/m4a → flac, originals removed
to-audio opus -b 192 -k ~/Music      # higher bitrate, keep originals
BITRATE=96 to-audio opus .

git sync                             # git dispatches git-sync as a subcommand
git squash develop

url2base64 https://ex.com/i.svg | pbcopy
dir=$(parallel-dl-extract https://ex.com/a.zip https://ex.com/b.zip | tail -1)

prune skips dotted directories and their subtrees entirely, so .git and .stfolder are never candidates, and collapses nested candidates to their topmost parent. vimv refuses if the line count changes, because a shifted list renames every file to its neighbour's name.

wtclean uses zarg too

It was already a script rather than a function — a GC pass has no reason to mutate the calling shell — so it drops its hand-rolled [[ $1 == -n ]] and gains --help, --version, typo correction and the first completion it has ever had.

The rest of wt-* deliberately doesn't. wt and wtrm cd, so they must stay functions, and zarg is wrong for functions on two counts: zarg_go calls exit, which in an interactive function kills the user's shell rather than the command, and typeset -g would leak parsed values and the ZARG_* spec arrays into the session. They keep compdef at load time.

That needed a generate:completions:plugin task — wtclean is reached through a shell function, so command -v wtclean finds nothing from inside a task and the usual generator skipped it silently.

Deliberate behaviour changes

Change Reasoning
to-audio flac|opus is a positional, not a subcommand They differed by one option (--bitrate, meaningless for lossless). Now ignored for flac rather than absent
prune measures blocks (du), not apparent size "How much do I get back by deleting this" is the question being asked
kill-port -s takes names only -s 9 now errors pointing at KILL rather than being silently accepted
Warning glyph is 󰀪 It was "" — an empty string, so warnings rendered with colour and no icon

Bugs fixed in the port

  • extract-exif-from-flac could never report "Clean". It substring-matched the sensitive-field list against the whole exiftool -json document, and exiftool always emits "SourceFile" — which contains Source. Every image came back dirty. Now asks exiftool for only the tags that matter, so an answer at all is the finding.
  • prune-gen's fixtures were sparse, so a "210MB" directory read as 16KB under du and the fixture had stopped exercising the threshold it exists to test.
  • Two (( x++ )) truthiness bugs: post-increment returns the old value, so (( ok++ )) || (( failed++ )) double-counted the first success as a failure, and (( failed )) && _dt_info ... as a last line made a clean run exit 1.
  • local path in prune — zsh ties $path to $PATH, so it silently emptied PATH mid-run and du/cut/sort started vanishing.

How to verify

zsh zsh-plugins/zarg/test.zsh      # 32 tests
scripts/lib/check-completions      # all 16 scripts' completions parse in zsh/bash/fish
task --list                        # Taskfile still resolves

Both run in CI (scripts.yml, replacing rust-tests.yml) and on pre-push. CI installs fish so the fish output is parsed by real fish rather than merely generated.

Each script was exercised against fixtures, not just --help: clean-dls classified 24 files correctly in both directions (including a filename containing a newline); to-audio round-tripped wav→flac→opus with spaces in filenames; clean-exif confirmed to strip Artist/Copyright/UserComment and to count a corrupt file as failed; vimv confirmed to use git mv and to refuse a line-count mismatch with exit 1; git-sync against a real deleted upstream while keeping a no-upstream branch; git-squash collapsing 2→1 with the tree intact; embed-artmetaflac picture block landed; parallel-dl-extract against a real zip over HTTP; url2base64 round-tripped through base64 -d. imp ran with stubbed aria2c/beet; unfuck-xcode only via --help/-n.

Fallout

link:cargo + packager.d/cargo-config.toml deleted, rust dropped from 03-tools.toml, ~/.dotfiles/bin off $PATH, cargo artefacts out of .gitignore, DOTFILES_NO_RUST escape hatch gone.

Still questionable

prune-gen is a test fixture generator that ships as a command and gets a shell completion. It survived because it was in scope, not because it earns its place — worth deleting separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_014CzNvwMVqrpyy2N1vhHQui

radiosilence and others added 3 commits August 7, 2026 12:34
15 Rust binaries (3097 lines, 326 transitive crates) become 15 zsh scripts
in scripts/, sharing output helpers in scripts/lib/common.zsh. Ten of the
fifteen shelled out to ffmpeg/metaflac/aria2c/unzip/beet/exiftool anyway;
the Rust was a progress bar around someone else's program.

Drops the C toolchain from Linux bootstrap (git2 vendored libgit2) and the
rust toolchain from mise. task reinstall-bins had not rebuilt anything since
its first run — its status guard was satisfied forever after.

zsh-plugins/zarg derives parsing, --help, --version and completions for
zsh/fish/bash from one declaration, so a script's completions cannot drift
from the flags it accepts. 32 tests, plus scripts/lib/check-completions
asserting every emitted completion parses in its target shell.

Three bugs fixed in the port: extract-exif-from-flac could never report
"Clean" (substring-matched the sensitive list against JSON containing
"SourceFile"); prune-gen's sparse fixtures made a 210MB dir read as 16KB;
the warning glyph was an empty string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CzNvwMVqrpyy2N1vhHQui
scripts/README.md documents each command with worked examples, and zarg's
README gains a section on using it from other plugins.

wtclean was already a script rather than a function, so it drops its
hand-rolled -n check for a zarg spec and gains --help, --version and its
first completion. The rest of wt-* stays hand-parsed: wt and wtrm cd, so
they must remain functions, and zarg_go calls exit — which in an interactive
function kills the shell, not the command.

Reaching wtclean goes through a shell function, so `command -v` finds
nothing from inside a task. Added generate:completions:plugin, which keys
off an executable path instead.

check-completions matched the new README (it documents zarg.plugin.zsh) and
tried to execute it. Now gated on the executable bit and a zarg_go call, and
its default sweep covers plugin bin/ dirs so wtclean is checked in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CzNvwMVqrpyy2N1vhHQui
scripts/README.md landed in a glob that assumed everything under scripts/
was a script. Gate on the executable bit, or a .zsh extension for sourced
files, and pick up plugin bin/ dirs while here so wtclean is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CzNvwMVqrpyy2N1vhHQui
@radiosilence
radiosilence merged commit 5d8703a into main Aug 7, 2026
2 checks passed
radiosilence added a commit that referenced this pull request Aug 7, 2026
wtclean moved to zarg in #82 and the other four were left hand-rolled,
which is exactly the drift zarg exists to prevent. One declaration now
yields the parser, --help and completions for all five.

Branch arguments reuse _wt_branches, the function already backing the wt
and wtrm compdefs, rather than a second way to list worktrees.


Claude-Session: https://claude.ai/code/session_01SdK2s5sJBVLc5Edhm3cZ3H

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
radiosilence added a commit that referenced this pull request Aug 7, 2026
* refactor(wt): parse the remaining bins with zarg

wtclean moved to zarg in #82 and the other four were left hand-rolled,
which is exactly the drift zarg exists to prevent. One declaration now
yields the parser, --help and completions for all five.

Branch arguments reuse _wt_branches, the function already backing the wt
and wtrm compdefs, rather than a second way to list worktrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdK2s5sJBVLc5Edhm3cZ3H

* refactor(wt): move wtclean's logic into the script

The plugin defined _wt_clean, _wt_pr_cache and _wt_fan, and bin/wtclean
was their only caller — every interactive shell parsed ~150 lines it
would never run. Worse, zarg parsed --dry-run into $dry_run and the
script re-encoded it back into -n so the function could parse it again,
with a comment explaining the gotcha that round trip created.

The genuinely shared helpers (_wt_root, _wt_base, _wt_named,
_wt_pr_state) stay in the plugin, where wtrm and the picker use them.

_wt_prs keeps its declaration next to _wt_pr_state rather than moving
with the code that fills it: an undeclared associative array makes
${_wt_prs[feat/x]} an arithmetic subscript, so "feat/x" is evaluated as
a division and any slashed branch name dies with "division by zero".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdK2s5sJBVLc5Edhm3cZ3H

* docs(wt): record the wtclean split and the _wt_prs subscript trap

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdK2s5sJBVLc5Edhm3cZ3H

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant