CLI tools a coding agent needs on a VPS
Install the command line tools a coding agent reaches for on a bare Ubuntu VPS, in three tiers, with the reason each one saves tokens and tool calls.
Why a coding agent needs CLI tools on a VPS
The CLI (command-line interface) tools a coding agent needs on a VPS (virtual private server) are the ones that turn a long answer into a short one. Every tool call the agent makes puts two things into its context window: the command it typed and everything that command printed. A search that returns five lines costs almost nothing. The same search returning five hundred lines is paid for on that call, and again on every later turn, because the model re-reads the whole conversation each time it responds. The right tools cut that cost at the source.
Every "tools for Claude Code" list assumes a laptop with Homebrew. This reader has a fresh Ubuntu server, an agent on it, and nothing else. A stock Ubuntu cloud image ships bash, grep, find, sed, awk, python3 and curl. It often does not ship git, and it does not ship jq, ripgrep, tree or fd. An agent on that box falls back to grep -r and find, which walk .git and node_modules and print everything they meet.
There is a second cost besides output size: retries. When the agent guesses that a tool exists, or guesses its binary name, and the guess is wrong, that is a failed tool call, then a second call to work out why, then a third to do the job. So the method below is simple. Install from the Ubuntu archive only. Check what each package actually installed. Write the answers down where the agent reads them.
If the agent is not on the server yet, running a coding agent on a VPS covers the base install, and keeping Claude Code alive in tmux covers the session it will live in. Everything below assumes a normal user with sudo.
Which tools are already installed?
Ask the shell before you install anything. command -v prints the path of a program if the shell can find it on PATH, prints nothing if it cannot, and exits non-zero in that case.
for t in rg fd fdfind jq git curl tree yq pandoc pdftotext qpdf hyperfine just identify ffprobe gh; do
printf '%-10s %s\n' "$t" "$(command -v "$t" || echo missing)"
doneEach line is a tool name followed by a path or the word missing. Keep this loop. Run it again after each install step, because it is also the shortest possible report of what the agent can use.
Tier 1: what the agent reaches for constantly
sudo apt update
sudo apt install -y ripgrep fd-find jq git curl treeripgrep: search that skips what the agent should never read
The ripgrep package installs the rg command. It reads .gitignore and skips whatever that file lists, and it skips binary files. grep -r does neither, so on a Node or Python project it prints matches from node_modules and from .git objects. Those lines are pure cost: the agent did not want them and now has to read past them.
rg -n 'TODO' --type py
rg -l 'DATABASE_URL'
rg -n --max-columns 200 --max-columns-preview 'fetch\(' src/-n adds line numbers, which is what the agent needs to open the file at the right place. -l prints only file names, the right first query when the agent wants to know where before it wants to know what. --max-columns 200 matters more than it looks. One match in a minified bundle is a single line that can run to hundreds of kilobytes, and without the limit that whole line lands in the context window. With it, rg prints the first 200 characters and a note that the line was cut.
To see how much rg filters for you, compare the file counts:
rg --files | wc -l
find . -type f | wc -lOn a checked-out project with dependencies installed, the second number is usually many times the first. That gap is what grep -r would have searched.
fd: check the binary name before the agent guesses it
fd is a find replacement with a short syntax and the same .gitignore awareness as rg. The Ubuntu package is called fd-find, and the command it installs may not be called fd, because Debian renamed the binary years ago to avoid a clash with an older package. The notes under /usr/share/doc/fd-find/ tell that story. The practical effect is what matters: the agent has read the upstream docs, so it types fd, gets command not found, and spends tool calls discovering the real name. Do not guess it either. Ask dpkg:
dpkg -L fd-find | grep bin/dpkg -L lists every file a package installed, and grep bin/ keeps the executables. The entry under /usr/bin/ is the name your shell knows. If that name is not fd, give the agent the name it expects with a link in your own bin directory:
mkdir -p ~/.local/bin
ln -sf "$(dpkg -L fd-find | grep '^/usr/bin/' | head -n 1)" ~/.local/bin/fd
export PATH="$HOME/.local/bin:$PATH"
command -v fdThe export line fixes the current shell. Ubuntu's default ~/.profile adds ~/.local/bin to PATH on every later login as long as the directory exists, so new tmux sessions get it too. command -v fd should now print a path under your home directory. If the package already ships the program as fd, the link points at that same program and does no harm.
fd -e md
fd -t f --changed-within 1d
fd -HI -e env-e filters by extension, -t f limits results to files, and --changed-within 1d finds what changed in the last day. -H includes hidden files and -I includes ignored ones, both of which fd skips by default; that pair is how the agent finds a .env file that .gitignore hides. The same dpkg -L check answers the binary-name question for any package whose upstream name and Debian name differ, so use it before you write a tool's name into the agent's instructions.
jq: five lines instead of five hundred
jq makes the biggest difference to context size, because so much of what an agent reads is JSON: API (application programming interface) responses, lock files, CI (continuous integration) output, package.json, cloud CLI results. Here is a worked example. Ask the GitHub API about the latest jq release and count the lines:
curl -s https://api.github.com/repos/jqlang/jq/releases/latest | wc -lThat prints a count in the hundreds. Most of those lines describe download assets for platforms you do not have, each with its own uploader block. An agent that runs this without a filter reads all of it, and so does every later turn of the conversation. Now ask for the five values that matter:
curl -s https://api.github.com/repos/jqlang/jq/releases/latest |
jq -r '.tag_name, .name, .published_at, .html_url, (.assets | length)'Five lines: the tag, the release name, the date, the page URL and the number of assets. -r prints strings without their quotes, and the comma separates several outputs from one input. Same information, a small fraction of the tokens. This is the pattern behind managing Claude Code's context window: the cheapest token is the one that never enters the window, and a filter at the shell is the earliest place to drop it.
Two habits belong in the agent's instructions. First, discover the shape before asking for values. keys is one short call, and a wrong guess at a path returns null with no explanation:
curl -s https://api.github.com/repos/jqlang/jq/releases/latest | jq 'keys'Second, use a filter to pull the few fields you need instead of dumping an object. This lists the scripts a project defines without printing the whole package.json:
jq -r '.scripts | to_entries[] | "\(.key): \(.value)"' package.jsonIf jq prints parse error: Invalid numeric literal, the input was not JSON. The usual cause is an HTML error page, or a warning line printed before the JSON, so look at the first bytes of the raw output with head -c 300.
git, curl and tree
git is often missing from a cloud image, and an agent without it cannot commit or read history, so it improvises with cp and diff. Install it and set the identity the agent will commit under, otherwise the first git commit stops with Author identity unknown:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"curl is already there, but the flags matter for an agent. -s silences the progress meter, which is otherwise noise in every result. -f makes curl exit non-zero on an HTTP error instead of printing the error page as if it were data, and -S still shows the error message when -s is on:
curl -fsS -o /dev/null https://api.github.com/repos/jqlang/jq/releases/latest; echo "exit $?"That prints exit 0, because the release exists. Change the repository name in the URL to one that does not exist and the same command prints curl: (22) The requested URL returned error: 404 followed by exit 22. Without -f, the missing repository prints a JSON body with a message field and exit 0, and an agent will parse that body as a result.
tree prints a directory as an indented outline. Its value for an agent is the depth limit. tree -L 2 --gitignore shows the top two levels, skips hidden files and whatever .gitignore covers, and ends with a one-line count of directories and files. Two levels is usually enough to orient. -L 4 on a real project is thousands of lines.
Tier 2: tools that keep the context lean
sudo apt install -y yq pandoc poppler-utils qpdfyq: the same filters, for YAML
Two unrelated programs are called yq. One is a Go program with its own flag set, distributed as a snap and as release binaries. The other is a Python wrapper that converts YAML to JSON and hands it to jq. Their filter languages overlap, but their flags do not, so an agent that learned one passes flags the other rejects. Read the description to find out which one the archive gave you:
apt show yq 2>/dev/null | grep '^Description'The archive package describes itself as a jq wrapper. That means every filter from the previous section works on YAML unchanged, and it needs jq installed, which Tier 1 already did. Output is JSON unless you ask for YAML with -y:
yq -r '.services | keys[]' docker-compose.yml
yq -y '.services.web.environment' docker-compose.ymlThe first prints one service name per line. The second prints only the block you asked for, as YAML, instead of the whole compose file. Write which yq you have into the project instructions. It is exactly the kind of fact an agent cannot infer from a name.
pandoc and pdftotext: documents into text the agent can search
An agent cannot rg a .docx file, and reading a PDF directly is expensive, since every page becomes image tokens or a large text dump. Convert once, then search the text.
pandoc spec.docx -t gfm --wrap=none -o spec.md-t gfm writes GitHub-flavoured markdown, so headings and lists survive and the agent can jump to a section by its heading. --wrap=none matters for search: by default pandoc wraps output at 72 columns, so a phrase that crosses a line break never matches an rg query for it.
poppler-utils installs pdftotext and pdfinfo:
pdfinfo report.pdf | grep Pages
pdftotext -layout -f 3 -l 5 report.pdf -pdfinfo tells you how big the job is before you start. pdftotext with -f and -l extracts only pages 3 to 5 to standard output, and -layout keeps columns aligned so a table stays readable as text. If pdftotext prints only blank lines, the PDF is a scan with no text layer. Check with pdffonts report.pdf: a scanned PDF lists no fonts. Text extraction cannot help there. That is an OCR (optical character recognition) job, and tesseract-ocr is in the archive if you need it.
qpdf: cut the PDF before anyone reads it
Some work needs the PDF itself, not its text: a layout question, a form, a diagram, or a document the agent will read with its own file reader. Hand it the smallest file that answers the question.
qpdf --show-npages contract.pdf
qpdf contract.pdf --pages . 12-15 -- clause.pdf
qpdf --split-pages=10 manual.pdf part.pdfThe first prints the page count. The second writes pages 12 to 15 into a new file, where . stands for the input file. The third splits a long manual into ten-page files named after part.pdf with the page range inserted before the extension, so a question about chapter three costs one small file rather than the whole manual. qpdf --decrypt --password=secret in.pdf out.pdf removes a password from a PDF you are allowed to open, which is the fix when pdftotext refuses the file with an incorrect-password error.
Tier 3: task-specific tools
sudo apt install -y hyperfine just imagemagick ffmpeg ghhyperfine: a performance claim becomes a number
Agents write "this should be faster" easily. hyperfine turns that into a measurement, with warmup runs and a JSON export the agent can read back through jq:
hyperfine --warmup 3 --export-json bench.json 'rg --files' 'find . -type f'
jq -r '.results[] | "\(.command): \(.mean) s"' bench.jsonThe terminal summary is for you. The second command prints one line per benchmarked command with its mean wall time in seconds, and that is the line an agent can quote in a commit message or compare before and after a change. If a benchmarked command exits non-zero, hyperfine stops with Command terminated with non-zero exit code and points you to -i, which ignores failures. Be careful accepting that: a command that fails fast is not a command that got faster.
just: a command runner the agent can list
A justfile records the commands a project needs, inside the project. It replaces the agent reconstructing the test command from the README each session, and it replaces reading a long Makefile or CI config to find it.
test:
python3 -m pytest -q
lint:
ruff check .
fmt:
ruff format .just --list
just testjust --list prints the recipe names, a handful of lines, and that is the whole interface the agent needs to learn. Recipe lines are indented, and mixing tabs and spaces inside one recipe is an error just reports before running anything. A misspelt recipe name gets an error naming the missing recipe and the closest match, so the agent's typo costs one call rather than a silent no-op.
imagemagick and ffmpeg: when the repo has media
Reading an image into an agent's context costs image tokens every time. Usually the agent only needs to know what the image is:
identify assets/logo.png
identify -format '%w x %h\n' assets/logo.pngThe first prints one line with the format and the pixel size. The second prints only the width and height. Both are a handful of characters instead of a whole image. ImageMagick's conversion command changed name between major versions, so check which one your image ships before writing it into a script:
command -v magick convertWhichever path prints is the one to use for resizing and format conversion. If both print, use magick.
For video and audio, ffprobe from the ffmpeg package does the same job, and -of json gives output that jq can trim:
ffprobe -v error -show_format -show_streams -of json clip.mp4 |
jq -r '.format.duration, (.streams[] | "\(.codec_type) \(.codec_name) \(.width // "") \(.height // "")")'That prints the duration and one line per stream. -v error hides the banner and build details ffprobe otherwise prints to standard error, which would land in the agent's tool output as noise.
gh: pull requests from the server
gh is GitHub's command-line client, and it is in the archive. On a server without a browser, log in with a token from standard input, then remove the file:
gh auth login --with-token < ~/gh-token.txt
rm ~/gh-token.txt
gh auth statusgh auth status should report that you are logged in to github.com. Plain gh auth login also works over SSH: it prints a one-time code and a URL you open in your laptop's browser. Whichever way you log in, the token ends up on a box where the agent has a shell, so the agent can read it. Use a fine-grained token limited to the one repository the agent works on, and read running Claude Code safely on a VPS before you hand an agent credentials of any kind.
The commands that save the agent reading:
gh pr create --fill
gh pr checks
gh run view <run-id> --log-failed
gh pr view --json state,mergeable --jq '.state'--fill takes the title and body from the commits. gh pr checks prints one line per CI check. --log-failed prints only the log of the failing job instead of the whole run, which is the difference between a screen and a book. gh has --jq built in, so the last line prints one word.
What is not in the archive
apt-cache policy answers "is it packaged" without installing anything:
apt-cache policy hurl duckdb sccA packaged name prints a Candidate: line with a version. A name the archive does not carry prints N: Unable to locate package instead. As of September 2026, scc and duckdb are absent from both LTS releases, and hurl is packaged for 26.04 but not for 24.04, so none of the three belongs in a setup that has to work on both images. The temptation is a curl ... | sh installer from the project's website. Do not do that on an agent's box. A piped installer runs whatever the server sent today, with sudo if you gave it sudo, and leaves no package record for dpkg -L to consult, so neither you nor the agent can later answer what it changed. If you need one of these tools, download a release archive by hand and check its published checksum. Put the binary in ~/.local/bin. That is a separate job, done outside the agent's session.
Tell the agent what it has
Everything above is wasted if the agent still has to discover it. The loop from the start of this guide is the inventory. Put its answers in the instructions file the agent reads at the start of every session:
Tools installed on this server:
- ripgrep as `rg`, `fd` (linked from the fd-find package), jq, tree, git, curl
- yq is the jq-wrapper flavour: jq syntax, add -y for YAML output
- pandoc, pdftotext, pdfinfo, qpdf for documents. Convert, then search the text.
- hyperfine for any timing claim. just for project commands: run `just --list` first.
- gh is logged in with a token scoped to this repository only.Keep it that short. Where these facts belong, and what stays in the docs you write for people, is covered in what belongs in AGENTS.md and what belongs in the human-facing docs. Two of the tiers above also use real resources: pandoc on a large document and ffmpeg on video need memory and CPU that a small plan does not have, which is what sizing RAM and CPU for a coding-agent VPS is about.
Run the inventory loop one last time. Every line should show a path, and the agent's next session starts with the toolkit instead of the guessing.
FAQ
Why does the agent get "command not found" for fd after I installed fd-find?
Because the Ubuntu package may install the program under a different name from the one the upstream project uses, and the agent types the upstream name. Run dpkg -L fd-find | grep bin/ to see the real name, then link it as fd in ~/.local/bin so the name the agent expects works. Write the outcome into the project's instructions file so the agent never has to rediscover it.
Do I need Homebrew or a Rust toolchain to install these tools on Ubuntu?
No. Every tool here is a package in the Ubuntu archive for both 24.04 and 26.04: ripgrep, fd-find, jq, tree, yq, pandoc, poppler-utils, qpdf, hyperfine, just, imagemagick, ffmpeg and gh. sudo apt install is the whole install, and dpkg -L tells you what each one put on disk.
Which yq does apt install on Ubuntu?
The jq wrapper, as apt show yq says in its description. It uses jq filter syntax, prints JSON by default and prints YAML when you pass -y. The Go program with the same name has different flags, so if an agent's yq command fails with an unknown-option error, the agent learned the other one. Put which one you have into the project instructions.
How does jq save a coding agent tokens?
Every line a command prints enters the agent's context and stays there for the rest of the session. A raw API response is hundreds of lines; jq -r '.tag_name, .published_at' on the same response is two. The filter drops the tokens before the model ever sees them, which is cheaper than any compaction the agent does later.
Is it safe to install tools with curl piped to sh on the agent's VPS?
Avoid it. The script runs whatever the server sent at that moment, often with sudo, and leaves no package record, so dpkg -L cannot tell you or the agent what changed. For a tool that is not in the archive, download a release archive yourself and verify its checksum. Then place the binary in ~/.local/bin, outside the agent's session.