Ghost in the Shell Script

I’ve been using Unix and Linux systems for nearly 20 years. When I found myself with a Windows laptop for a while I felt lost and quickly installed Windows Subsystem for Linux. And yet the number of commands I know and use regularly is surprisingly short: cd, ls, rm, pwd, mkdir, source, node, npm and, of course, git. Even then I don’t think I really “know” git.
In his excellent book “Don’t Make Me Think Revisited”, Steve Krug describes how
Faced with any sort of technology, very few people take the time to read instructions. Instead, we forge ahead and muddle through, making up our own vaguely plausible stories about what we’re doing and why it works.
Krug’s book is about web and mobile usability but the point he makes here applies across technological domains. And it is safe to say I’ve been “muddling through” with Unix. If you asked me whether something was a Unix command or part of the shell I’d probably just shrug and say something like “I… I don’t know that!?".
Until recently StackOverflow had always been my crutch to fall back on. From the frequency with which it appears in answers, I know that find is a very useful and versatile command. However, the odds of me remembering how to use it without looking it up are indistinguishable from 0.
But now we have alternatives in the form of various large language models or, to be more precise, multi-modal AI Assistants (like Claude and ChatGPT) and Agents (like Claude Code and Codex). I’d describe myself as AI-agnostic: I see benefits, I worry about issues. So it was with some (I’d say healthy) degree of scepticism I started using Claude to assist me in writing some shell functions to automate tasks I do or would like to do regularly.
I say “I” wrote. Reality is of course that I asked Claude to write and rewrite things for me and then I questioned why it did what it did. This probably makes it sound like I was assessing Claude for a job but it’s actually that I want to understand what has been written and, as we’ve already covered, I am not very good at this stuff.
Task One: fixing a common git mistake
One of my most common slip-ups with git is to commit changes to one branch (usually main) that were meant to go on a new feature branch. I know a way to recover from this: create the new branch I meant to create all along; do an interactive rebase to remove the commit from main; switch to the new feature branch I just created. There’s not that many steps to the process but it’s still a bit of a faff. Let’s ask Claude:
Yes — assuming you haven’t pushed yet:
# Create and switch to the correct branch from current state
git switch -c correct-branch
# Go back to the wrong branch and undo the commit (keeps changes staged)
git switch wrong-branch
git reset --soft HEAD~1
# Discard the changes
git restore --staged .
git restore .
Or if you want to keep it even simpler — if the commit is only on the wrong branch and not pushed:
git switch -c correct-branch
git switch wrong-branch
git reset --hard HEAD~1
The switch -c creates the new branch pointing at the same commit, so your work is preserved there. Then you just strip it off the wrong branch with reset –hard.
Here we hit a couple of issues.
- Neither of Claude’s suggestions did the final step of what I wanted: moving me back to the new, correct, branch. This is entirely on me. I forgot to ask (and didn’t notice for a bit). I do that kind of thing quite a lot.
- Even for simple tasks, Claude’s responses can be quite verbose.
The problem here is not a practical one regarding the use of Claude or an alternative agent, the real problem is it’s quite difficult to write a blog post of a sensible length that actually goes anywhere while accurately recounting the conversation you have with Claude. So what follows is a paraphrase rather than a transcript; shortened for the sake of your sanity and mine.
git switch - saves you typing the wrong branch name out explicitly.Agreed. Something like this (with an added safety check):
git-move-last() {
if [ -z "$1" ]; then
echo "Usage: git-move-last <new-branch-name>"
return 1
fi
git switch -c "$1"
git switch -
git reset --hard HEAD~1
}
Ha 🪓.
Exactly:
git-behead() {
if [ -z "$1" ]; then
echo "Usage: git-behead <new-branch-name>"
return 1
fi
git switch -c "$1"
git switch -
git reset --hard HEAD~1
git switch -
}
git-behead() {
if [ -z "$1" ]; then
echo "Usage: git-behead <new-branch-name> [commit-count]"
return 1
fi
local count="${2:-1}"
git switch -c "$1"
git switch -
git reset --hard HEAD~"$count"
git switch -
}
git-behead() {
if [ -z "$1" ]; then
echo "Usage: git-behead <new-branch-name> [commit-count]"
return 1
fi
local count="${2:-1}" # default to 1 commit if not specified
git switch -c "$1" # create new branch at current HEAD
git switch - # switch back to original branch
git reset --hard HEAD~"$count" # remove commits from original branch
git switch - # switch to new branch
}
Putting this in my .bashrc file gives me a simple command that I can invoke whenever I make the mistake I regularly make and an optional argument to use whenever I haven’t been paying attention to the branch I’m on for an extended period. It’s also short enough that I think I follow every line, despite my limited knowledge of Bash, and there are comments there for when future me has forgotten. This conversation took about fifteen minutes and included more questions than I’ve included here, plus time I spent running various ad-hoc tests with the different iterations of the function. That seems like a pretty efficient use of my time.
Task Two: listing image files
I do a lot of data visualisation, I look after the artwork for this website and I take photographs for our conferences. In short, I deal with a lot of images in various formats. It would be nice to be able to list only images and their key properties in a specific location with a simple function. Let’s ask Claude:
Here’s a clean script using identify from ImageMagick (widely available) with a du fallback for file size:
#!/usr/bin/env bash
DIR="${1:-.}"
if ! command -v identify &>/dev/null; then
echo "Error: imagemagick is required (install with: brew install imagemagick / apt install imagemagick)" >&2
exit 1
fi
shopt -s nullglob nocaseglob
files=("$DIR"/*.{jpg,jpeg,png,gif,webp,bmp,tiff,tif,avif})
shopt -u nullglob nocaseglob
if [[ ${#files[@]} -eq 0 ]]; then
echo "No images found in: $DIR" >&2
exit 0
fi
printf "%-40s %8s %8s %10s\n" "FILENAME" "WIDTH" "HEIGHT" "SIZE (kB)"
printf "%-40s %8s %8s %10s\n" "--------" "-----" "------" "---------"
for f in "${files[@]}"; do
name="$(basename "$f")"
read -r width height < <(identify -format "%w %h" "$f" 2>/dev/null | head -1)
size_kb=$(awk "BEGIN { printf \"%.1f\", $(wc -c < "$f") / 1024 }")
printf "%-40s %8s %8s %10s\n" "$name" "$width" "$height" "$size_kb"
done
Supported formats: jpg, jpeg, png, gif, webp, bmp, tiff, avif.
Example output:
FILENAME WIDTH HEIGHT SIZE (kB)
-------- ----- ------ ---------
banner.png 1200 630 84.3
avatar.jpg 400 400 32.1
hero.webp 1920 1080 210.7
I asked for a script, I got a script. I wanted a function. This one is, again, on me.
The key changes: shebang removed, exit replaced with return, and variables are now local. To use it, source it in your shell and call it as normal:
imgls() {
local dir="${1:-.}"
if ! command -v identify &>/dev/null; then
echo "Error: imagemagick is required (install with: brew install imagemagick / apt install imagemagick)" >&2
return 1
fi
shopt -s nullglob nocaseglob
local files=("$dir"/*.{jpg,jpeg,png,gif,webp,bmp,tiff,tif,avif})
shopt -u nullglob nocaseglob
if [[ ${#files[@]} -eq 0 ]]; then
echo "No images found in: $dir" >&2
return 0
fi
printf "%-40s %8s %8s %10s\n" "FILENAME" "WIDTH" "HEIGHT" "SIZE (kB)"
printf "%-40s %8s %8s %10s\n" "--------" "-----" "------" "---------"
local f name width height size_kb
for f in "${files[@]}"; do
name="$(basename "$f")"
read -r width height < <(identify -format "%w %h" "$f" 2>/dev/null | head -1)
size_kb=$(awk "BEGIN { printf \"%.1f\", $(wc -c < "$f") / 1024 }")
printf "%-40s %8s %8s %10s\n" "$name" "$width" "$height" "$size_kb"
done
}
The conversation goes on a bit from here and the function becomes more and more detailed. It would be tedious to show the whole conversation but to summarise, “we”:
- Add support for SVG, including extracting dimensions from the
viewBoxattribute. - Fix a bug with GIF dimensions.
- Tweak the header text.
- Add support for recursive searching of a directory.
- Fix the layout when doing a recursive search.
- Round the values in the SIZE column
- Add comments.
The final result:
imgls() {
local recursive=0
# Parse flags — currently only -r (recursive) is supported
while getopts ":r" opt; do
case $opt in
r) recursive=1 ;;
*) echo "Usage: imgls [-r] [directory]" >&2; return 1 ;;
esac
done
# Shift past any parsed flags so $1 is the directory argument
shift $((OPTIND - 1))
OPTIND=1
# Use first argument as directory, default to current directory if omitted
local dir="${1:-.}"
# identify is provided by imagemagick and used to read raster image dimensions
if ! command -v identify &>/dev/null; then
echo "Error: imagemagick is required (install with: brew install imagemagick / apt install imagemagick)" >&2
return 1
fi
local exts=("jpg" "jpeg" "png" "gif" "webp" "bmp" "tiff" "tif" "avif" "svg")
local files=()
if [[ $recursive -eq 1 ]]; then
# Build a find command matching all extensions, case-insensitively
local find_args=("$dir" "-type" "f" "(")
for i in "${!exts[@]}"; do
[[ $i -gt 0 ]] && find_args+=("-o")
find_args+=("-iname" "*.${exts[$i]}")
done
find_args+=(")")
while IFS= read -r f; do
files+=("$f")
done < <(find "${find_args[@]}" | sort)
else
# nullglob: unmatched globs expand to nothing instead of being treated as literals
# nocaseglob: match extensions case-insensitively (e.g. .JPG as well as .jpg)
shopt -s nullglob nocaseglob
files=("$dir"/*.{jpg,jpeg,png,gif,webp,bmp,tiff,tif,avif,svg})
shopt -u nullglob nocaseglob
fi
if [[ ${#files[@]} -eq 0 ]]; then
echo "No images found in: $dir" >&2
return 0
fi
# In recursive mode show the relative path, otherwise just the filename
printf "%-40s %11s %12s %10s\n" "FILENAME" "WIDTH (px)" "HEIGHT (px)" "SIZE (kB)"
printf "%-40s %11s %12s %10s\n" "--------" "----------" "-----------" "---------"
local f name width height size_kb viewbox current_subdir="" subdir
for f in "${files[@]}"; do
if [[ $recursive -eq 1 ]]; then
# Print a header line when we enter a new subdirectory
subdir="$(dirname "${f#$dir/}")"
if [[ "$subdir" != "$current_subdir" ]]; then
[[ -n "$current_subdir" ]] && echo ""
echo "${subdir}/"
current_subdir="$subdir"
fi
name="$(basename "$f")"
else
name="$(basename "$f")"
fi
# wc -c counts raw bytes; awk converts to kB with one decimal place
size_kb=$(awk "BEGIN { printf \"%d\", $(wc -c < "$f") / 1024 }")
if [[ "${f,,}" == *.svg ]]; then
# SVGs are XML so we can parse dimensions directly without imagemagick.
# Prefer viewBox (e.g. "0 0 100 200") as it's the most reliable source
# of the intended coordinate space; fall back to width/height attributes.
viewbox=$(grep -oi 'viewBox="[^"]*"' "$f" 2>/dev/null | head -1 | grep -oi '"[^"]*"' | tr -d '"')
if [[ -n "$viewbox" ]]; then
# viewBox is "min-x min-y width height" — fields 3 and 4
width=$(echo "$viewbox" | awk '{printf "%g", $3}')
height=$(echo "$viewbox" | awk '{printf "%g", $4}')
else
width=$(grep -oi 'width="[^"]*"' "$f" 2>/dev/null | head -1 | grep -oi '"[^"]*"' | tr -d '"px"')
height=$(grep -oi 'height="[^"]*"' "$f" 2>/dev/null | head -1 | grep -oi '"[^"]*"' | tr -d '"px"')
fi
# ${var:--} expands to "-" if var is empty or unset
width="${width:--}"
height="${height:--}"
else
# [0] selects only the first frame, preventing animated GIFs from
# producing one line of output per frame
read -r width height < <(identify -format "%w %h" "${f}[0]" 2>/dev/null)
fi
printf "%-40s %11s %12s %10s\n" "$name" "$width" "$height" "$size_kb"
done
}
The “development time” here was longer and I’d be fibbing if I said I understood every line of this function. But I do get the general principles and understand what it can and can’t do. And being able to list all images (of nearly all relevant types) in a directory with just their key properties in a nice tabular format is a very nice productivity win for me.
Final Thoughts
Discussions about AI easily tend towards the extremes: ‘it’s amazing’, ‘it’s a disaster’, ‘it’s useless’. This is particularly unhelpful when focusing on how successfully or otherwise it executes a single task. All we’ve really done in this post is create a couple of Bash scripts. For me the AI in question, Claude, has been useful. No more, no less. Despite this, the process did make me think a lot about how the way we interact with machines is changing. While it’s easy to over-personify AI, this line from the film that gave this blog post its title seems now, in 2026, to have been prescient:
The advent of computers, and the subsequent accumulation of incalculable data has given rise to a new system of memory and thought parallel to your own.
