Run a block of shell commands, watch the output, then decide whether to put it in the clipboard.
cli2clip <<'EOF'
git status --short
git log --oneline -3
EOF==== git status --short
M README.md
==== git log --oneline -3
41efa13 tools: add build-bpftool-for-guest.sh
2ce8325 podman: isolate container networking in its own netns
9f20ca3 chore: remove leftover debug prints
copy output to clipboard? [Enter, y, Y] yes, any other key no:
copied: 24 lines, 1213 bytes
Each command is echoed above its own output, which is what makes the transcript
readable once it is pasted somewhere else: three ls in a row, or the same
command run in three directories, are otherwise impossible to tell apart.
Copying is what you almost always want, so it is bound to the reflex key: press
Enter to copy, or y if that is what your fingers type at a yes/no question.
Any other key skips it.
It exists for a specific, repetitive situation: you are working with an assistant, or a colleague, in a chat window, and the loop is always the same — they give you commands, you run them in a terminal, you have to get the output back into the chat. Selecting a screenful of text with the mouse, over ssh, inside tmux, is tedious and error-prone, and it silently truncates.
Two details make the difference:
- the clipboard is written only at the end, and only if you say so. A block that takes twenty seconds must not hold your clipboard hostage while it runs: in those twenty seconds you may well need it for something else. Anything you typed or pasted meanwhile is discarded before the question is asked, so it can neither answer it nor fall through to the shell;
- nothing is lost to the scrollback. In bash the capture is the real stdout
and stderr of the commands, not whatever happened to be visible on screen. In
PowerShell the pipeline objects are rendered to text as they are captured, so
a wide table is wrapped or truncated at the console width exactly as it is on
screen: add
| Out-String -Width 200when the full width matters.
If you answer no, the capture file is kept and its path printed, so you can still copy it later.
Linux, bash. Uses curl or wget, whichever the machine has — a bare server
often has only one of the two:
URL=https://raw.githubusercontent.com/StefanoSalsano/cli2clip/main/cli2clip.sh
if command -v curl >/dev/null 2>&1; then curl -fsSL "$URL" -o ~/.cli2clip.sh
elif command -v wget >/dev/null 2>&1; then wget -qO ~/.cli2clip.sh "$URL"
else echo "cli2clip: neither curl nor wget found" >&2; fi
grep -q cli2clip ~/.bashrc 2>/dev/null || echo '[ -f ~/.cli2clip.sh ] && . ~/.cli2clip.sh' >> ~/.bashrc
. ~/.cli2clip.sh 2>/dev/null
if type cli2clip >/dev/null 2>&1; then
printf '\ncli2clip is installed and ready.\n\n cli2clip <<%sEOF%s\n hostname\n EOF\n\nEnter or y copies the output, any other key skips it.\n\n' "'" "'"
else
echo "cli2clip: installation failed, ~/.cli2clip.sh was not sourced" >&2
fiWindows, PowerShell:
$url = 'https://raw.githubusercontent.com/StefanoSalsano/cli2clip/main/cli2clip.ps1'
Invoke-WebRequest -Uri $url -OutFile "$HOME\.cli2clip.ps1"
if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null }
if (-not (Select-String -Path $PROFILE -Pattern cli2clip -Quiet)) { Add-Content $PROFILE '. "$HOME\.cli2clip.ps1"' }
. "$HOME\.cli2clip.ps1"
if (Get-Command cli2clip -ErrorAction SilentlyContinue) {
Write-Host "`ncli2clip is installed and ready.`n`n cli2clip { hostname }`n`nEnter or y copies the output, any other key skips it.`n"
} else {
Write-Host "cli2clip: installation failed, `$HOME\.cli2clip.ps1 was not sourced" -ForegroundColor Red
}Both are idempotent: running them again updates the script without duplicating
the line in the profile. Both end by checking that the function is actually
defined, so a silent failure — a download that produced an empty file, a profile
that is not read — is reported instead of surfacing later as
cli2clip: command not found.
The tool is half of a loop whose other half is the assistant you are talking to
— Claude, ChatGPT, or anything else. It will not know about cli2clip on its
own, and it cannot read this repository. Paste the following into the
conversation once, at the start, and it will start producing blocks you can run
as they are:
When you need me to run commands and report the output back to you, give me a
single cli2clip block instead of loose commands.
On Linux, bash:
cli2clip <<'EOF'
<your commands>
EOF
On Windows, PowerShell:
cli2clip { <your commands> }
It runs the block, shows me the output, and when the block has finished it asks
whether to copy it. I press Enter and paste it back to you.
Rules for those blocks:
- one block, ready to paste as it is, with no placeholders for me to fill in;
- begin with an absolute cd, never rely on the directory I happen to be in;
- state in a comment which machine and which shell the block is for, when there
is more than one;
- add "|| true" where a non-zero exit is expected (grep finding nothing, for
instance), otherwise it is reported as a failure;
- that failure marker exists on Linux only. In PowerShell nothing tells me that
a command failed, so where it matters, test $LASTEXITCODE in the block and
print something I can see;
- in PowerShell a Set-Location inside the block stays in effect afterwards, which
is one more reason to start every block with an absolute cd;
- on Linux, each command is normally echoed above its own output, but that is
dropped for a block containing a multi-line loop or a here document, because
such a block cannot be split. When you write one of those, put explicit echo
lines in yourself to separate the sections;
- to create a file, do not paste its contents into the terminal: long pastes
get mangled, especially through ssh and nested sessions. Give me the file
another way and a command to put it in place.
The last two rules are the ones that repay themselves fastest. Assistants
produce grep pipelines constantly, and a false !! FAILED on every one of
them trains you to ignore the marker that matters. And a here-document of a
hundred lines pasted through several hops of ssh will, sooner or later, arrive
corrupted in a way that is very hard to notice.
The bash version takes the commands on standard input, so use a quoted
heredoc (<<'EOF', with the quotes) unless you want the calling shell to expand
things before they are run:
cli2clip <<'EOF'
for f in /etc/hostname /etc/machine-id; do echo "--- $f"; cat "$f"; done
EOFThe PowerShell version takes a script block, which is multi-line by nature:
cli2clip {
Get-ChildItem C:\GITs -Directory | Select-Object Name, LastWriteTime
}Anything the shell can do works inside the block: loops, pipelines, heredocs,
function definitions. Variables set inside do not leak into your session, but
they are visible to the following commands within the block, so URL=... on
one line and wget "$URL" on the next work as you would expect.
The current directory behaves differently on the two shells. Bash runs the block
in a subshell, so a cd inside it is gone when the block ends. PowerShell has no
subshell to run it in: the current location belongs to the session rather than to
the scope, so a Set-Location inside a block is still in effect afterwards. The
habit that works on both is to begin every block with an absolute cd, rather
than relying on where the previous one left you.
Commands inside a block cannot read from the terminal: the block's standard
input is empty on both shells (in bash it is the heredoc, already consumed; in
PowerShell the process's stdin is pointed at NUL for the duration of the
block). A command that would wait for keyboard input -- ssh without -n, a
password prompt -- gets end-of-file instead of hanging the block, which is the
behaviour you want in a transcript. Input fed to a command within the block --
"..." | ssh host "bash -s", a here document -- is unaffected. Commands that do
need you at the keyboard belong outside a block.
In bash, echoing each command separately is only possible when every line is a
complete command. If the block contains a multi-line for, a here document or a
line continuation, splitting it would change what it does, so it is run as a
whole and the labels are dropped. Labels are a convenience; running the block
correctly is not. A one-line loop — for f in a b; do echo $f; done — is a
single complete command and keeps its label.
The PowerShell version has no such limitation: it asks the parser for the
top-level statements, so a multi-line foreach is one statement and stays
intact.
Note that neither version traces inside loops and functions, which is what
set -x would do. That would triple the length of the transcript with
implementation details, and the transcript is the thing you are about to paste
somewhere.
In the bash version every command that exits non-zero is reported where it fails, without stopping the block:
!! FAILED (exit 2): ls /nonexistent
A diagnostic block usually wants the remaining commands to run even if one of
them fails, which is why the block is not aborted. Commands whose failure is
already handled stay silent — grep pattern file || true, if cmd; then,
a && b — so the expected non-zero exits of tools like grep do not produce
noise.
The PowerShell version does not do this: PowerShell has no equivalent that catches the exit status of native commands without wrapping each one, and wrapping each one would rule out the multi-line constructs that make the script block worth using.
On Windows, Set-Clipboard writes to the local clipboard.
On Linux there is no clipboard to write to — X11 or Wayland may not even be running on the machine you are logged into. The bash version therefore goes through tmux, which forwards the text to the terminal emulator using the OSC 52 escape sequence. The clipboard that ends up holding the text is the one of the machine where your terminal runs, which is what you want when you are working over ssh.
This has two consequences worth knowing:
- it needs a running tmux server on the machine where
cli2clipruns, andset-clipboardenabled (tmux show -g set-clipboard, set it withtmux set -g set-clipboard on); - it needs a terminal emulator that supports OSC 52. Windows Terminal, iTerm2, kitty, foot and recent xterm do; some others do not, and a few impose a size limit on what they will accept.
If tmux is not reachable, nothing is lost: the capture file stays and its path is printed.
If you are several hops deep — ssh into a host, then into a container, then into
a virtual machine — the innermost shell usually has no tmux server of its own,
and cli2clip there will just keep the file. That is expected. The tmux session
you started at the outer hop can still see everything that scrolled past, so
capture from there instead:
tmux capture-pane -p -S -500 | tail -n 100 | tmux load-buffer -w -MIT. See LICENSE.