History expansion like !! and !$ is an interactive shell feature. In a script it is off, so !$ is passed through as a literal filename. Here is the failure, the flag that explains it, and what to write instead.
The short answer, before anything else: history expansion is a feature of interactive shells. A script runs non-interactively, where the feature is off by default, so !$ is never expanded. It is passed along as a literal two-character filename. Run echo $- and look for an H. If there is no H, expansion is off and every !! in that shell is just text.
Everything below was captured from a real shell session, not retyped from memory. When a line shows bash repeating your command back, that is bash printing the expansion before it runs it.
!! repeats the previous command. Bash prints what it expanded to first, so you always see what is about to run.
root@ops-01:/srv/app# cat config/app.conf
listen 8080
workers 4
root@ops-01:/srv/app# !!
cat config/app.conf
listen 8080
workers 4
!$ is the last argument of the previous command. This is the one that earns its keep: you list a file, then want to do something else to the same path, and you never retype it.
root@ops-01:/srv/app# ls -l logs/app.log
-rw-r--r-- 1 root root 14 Aug 4 10:12 logs/app.log
root@ops-01:/srv/app# wc -l !$
wc -l logs/app.log
2 logs/app.log
!^ is the first argument instead, which is what you want after a cp or an mv, where the interesting path is the source rather than the destination.
root@ops-01:/srv/app# cp config/app.conf /tmp/backup.conf
root@ops-01:/srv/app# head -1 !^
head -1 config/app.conf
listen 8080
^old^new^ reruns the previous command with the first match replaced. It is the fastest fix for a one-character typo, and it shows you the corrected command before running it.
root@ops-01:/srv/app# grep listen config/app.conf
listen 8080
root@ops-01:/srv/app# ^listen^workers^
grep workers config/app.conf
workers 4
!word reruns the most recent command that started with that word. Useful when the command you want is twenty lines back and you remember only how it began.
root@ops-01:/srv/app# !grep
grep workers config/app.conf
workers 4
history shows the numbered list all of this is drawing from.
root@ops-01:/srv/app# history 5
7 head -1 config/app.conf
8 grep listen config/app.conf
9 grep workers config/app.conf
10 grep workers config/app.conf
11 history 5
Notice line 10. When ^listen^workers^ ran, bash recorded the expanded command, not what was typed. History stores what actually executed.
Here is a small script. It uses !$ exactly the way it worked at the prompt a moment ago.
root@ops-01:/srv/app# cat checksize.sh
#!/bin/bash
ls -l logs/app.log
wc -l !$
Running it does not do what the prompt did.
root@ops-01:/srv/app# bash checksize.sh
-rw-r--r-- 1 root root 14 Aug 4 10:12 logs/app.log
wc: '!#39;: No such file or directory
Read that error carefully, because it is the reason this wastes people's time. Bash did not complain about the syntax. It did not warn that history expansion was unavailable. The first line worked fine. Bash simply passed !$ through untouched, and wc went looking for a file named !$, which does not exist.
The failure is silent about its actual cause, and it appears halfway through a script that was working a second ago at the prompt.
Bash keeps its current option flags in $-. Interactively, there is an H in there, and H means history expansion is enabled.
root@ops-01:/srv/app# echo $-
himBHs
Run the same thing non-interactively and the H is gone.
root@ops-01:/srv/app# bash -c 'echo $-'
hBc
That is the whole story. i for interactive and H for histexpand are both present in the first, and both absent in the second. A script gets the second shell.
You can watch the feature switch off in your own session. set +H disables history expansion, and from that moment !$ is ordinary text.
root@ops-01:/srv/app# set +H
root@ops-01:/srv/app# echo !$
!$
set -H on its own does not fix the script. Expansion still fails with the same error, because H is only half of what the feature needs. A shell also has to be recording a history list for !$ to point into, and that is a separate option which is likewise off non-interactively.
root@ops-01:/srv/app# bash -c 'set -H; set -o | grep histexpand'
histexpand on
root@ops-01:/srv/app# bash -c 'set -H; set -o | grep -w history'
history off
Turn on both and history expansion genuinely works inside a script.
root@ops-01:/srv/app# cat withhistory.sh
#!/bin/bash
set -H
set -o history
ls -l logs/app.log
wc -l !$
root@ops-01:/srv/app# bash withhistory.sh
-rw-r--r-- 1 root root 14 Aug 4 10:12 logs/app.log
wc -l logs/app.log
2 logs/app.log
There is the expansion echo, in a script, doing exactly what it does at the prompt.
Do not use it. The point above is that you *can*, not that you should.
!$ at a prompt refers to something you typed a second ago and can still see on screen. In a script it refers to whatever line ran before it, which changes the moment somebody inserts a line above it. Nothing about the reference is visible at the point of use, and no reader expects history expansion in a file.
Name the thing once and use the name:
log=logs/app.log
ls -l "$log"
wc -l "$log"
That is about as short, it cannot be broken by reordering, and it works in every shell whatever the flags say.
| Sequence | Expands to | In a script by default |
|---|---|---|
!! | the entire previous command | No |
!$ | last argument of the previous command | No |
!^ | first argument of the previous command | No |
!n | command number n from history | No |
!word | most recent command starting with word | No |
^old^new^ | previous command, first match replaced | No |
echo $- | current shell flags, H means expansion is on | Yes |
set +H / set -H | turn the histexpand option off / on | Yes |
set -o history | turn on the history list itself, needed too | Yes |
The six expansions read No because both options are off in a script unless you turn them on, which the section above shows how to do and argues against.
Verified on GNU bash 5.2.15 on Debian 12. The behaviour is long standing, but the flag names are bash specific and do not carry over to dash, zsh, or fish.
Why does !$ work when I type it but not in my script? History expansion is enabled only in interactive shells. Scripts run non-interactively, so !$ is passed through as a literal string. Check with echo $-: no H means no expansion.
Why did my script say No such file or directory instead of a syntax error? Because there is no syntax error. Bash treated !$ as an ordinary word and handed it to the command as an argument, so the command reported that it could not find a file by that name.
Can I enable history expansion in a script with set -H? Not with set -H alone. That turns on the histexpand option but leaves the history list itself off, so there is still nothing for !$ to point into and you get the same error. Adding set -o history as well does make it work. It is still a poor idea in a file, because the reference is invisible at the point of use and breaks as soon as a line is inserted above it. Use a variable.
Why does history show a different command than the one I typed? Because bash records what ran, not what you typed. After ^old^new^, the history entry is the substituted command.
More from the Field Manual, or work through the training tracks.