The CLI tools I use every day

By

The command line tools I use every day on my Mac: Ghostty, fish, zoxide, tmux, jq, SSH, Git, gh, Wrangler, gitleaks, coding agent CLIs, and the CLIs I built.

~~~

These are the command line tools I use every day on my Mac. Not everything I have installed, because that list is long and I lost track of it. Only the tools I open on a normal working day.

For each one: what it is, how I use it, the command I type most, how to install it, and the thing that bit me. Almost everything installs with Homebrew (formulae for command line programs, --cask for apps), and the install lines are for macOS. On Linux, use your package manager. If the terminal itself is new to you, the free Shell Commands course and the Command Line Guide cover the basics I skip here.

The order follows a working day: terminal, shell, servers, Git, deploying, secrets, coding agents, then the small CLIs I built for myself.

Ghostty

Ghostty is my terminal. It’s a native macOS app with GPU rendering, tabs and splits, and very little chrome.

My whole config is three lines:

font-size = 18
macos-option-as-alt = false
keybind = super+bracket_right=increase_font_size:1

Font and theme are the defaults. The size is big because I like it big. macos-option-as-alt = false keeps the Option key producing the characters my Italian keyboard expects. The keybind exists because + on that keyboard sits where a US layout has ], so Cmd and plus did nothing until I bound the physical key.

brew install --cask ghostty

The gotcha: Ghostty loads two config files, ~/.config/ghostty/config and the one it creates under ~/Library/Application Support/com.mitchellh.ghostty. Both apply, and the second one wins. I had settings in both for a while and couldn’t work out why a change did nothing. Keep the one in ~/.config.

The deep dive into Ghostty has the full option reference and the default shortcuts.

fish

fish is my shell, on the Mac and on my Linux machine. Autosuggestions from history, syntax highlighting while you type, and tab completions that explain each option, with zero plugins.

The configuration is one file, ~/.config/fish/config.fish. Mine is mostly PATH additions and a few aliases. ls is aliased to eza, a replacement for ls with colors and Git status. The alias I use most ties two other tools from this list together:

alias cdls='cd (zoxide query --list | fzf --header "Choose directory:")'

It lists every directory zoxide knows about and lets me pick one with fzf.

Install fish and make it the login shell:

brew install fish
command -v fish | sudo tee -a /etc/shells
chsh -s "$(command -v fish)"

Gotcha: fish has its own syntax. export EDITOR=zed becomes set -gx EDITOR zed, and $(...) becomes (...). Bash scripts still run because they start with #!/bin/bash. What trips people is a bash one-liner pasted from a README. Don’t translate it, run it with bash -c '...'.

The Fish Shell: A Complete Guide covers variables, functions, abbreviations, and the differences from Bash.

zoxide

zoxide is a smarter cd. It remembers the directories you visit and ranks them by how often and how recently you were there. Then you jump with a fragment of the name:

z flaviocopes

That takes me to /Users/flaviocopes/www/flaviocopes.com from anywhere. z dev things matches a path containing both words, /Users/flaviocopes/dev/things-cli here, and zi opens the matches in fzf when I’m not sure.

In fish, setup is one line in config.fish:

zoxide init fish | source

Install both, because zi needs fzf:

brew install zoxide
brew install fzf

Gotcha: a fresh zoxide knows nothing. The database fills as you cd around, so on day one it feels broken. Give it a week. I also left cd alone instead of replacing it with z, so an exact path still means an exact path.

How to use zoxide has the zsh and bash setup too.

tmux

tmux keeps a terminal session alive after you disconnect from it. On the Mac I rarely need that, because Ghostty has tabs and splits. On a server, tmux is the first thing I start.

ssh newsletter
tmux new -s migration

Then I run the long thing inside it, a database import or a coding agent working through a task. If the connection drops, the session keeps running on the server, and I attach again:

tmux attach -t migration

Ctrl-b d detaches, and tmux ls lists the sessions. I name them after the project or the server role, never work2, because a week later I have no idea what work2 was. On the Mac I run tmux with no config file at all.

brew install tmux

Gotcha: a tmux session survives a dropped connection, not a reboot. A program that must start at boot and restart when it crashes belongs in a systemd service.

A deep dive into tmux goes through windows, panes, copy mode, and configuration.

jq

jq filters and reshapes JSON from the command line. I use it to inspect API responses and to pull one field out of whatever a CLI printed with --json.

curl -s https://api.github.com/repos/flaviocopes/things-cli | jq '.stargazers_count'

I start with . to see the whole document, then add one key at a time until the output is what I need. When a filter grows past one line, it goes into a .jq file and I pass it with -f. Past that, a ten-line Node script is usually clearer.

macOS 15 ships jq in /usr/bin (mine reports jq-1.7.1-apple, checked September 2026). On an older macOS:

brew install jq

Gotcha: quoting. The filter goes in single quotes so the shell leaves | and $ alone, and string values inside it use double quotes. Also, when the input is JSON Lines, one object per line, jq already reads it as a stream, no wrapping needed. I wrote about that format in the JSONL guide.

The jq command covers selectors, iteration, map, and building new objects.

SSH

SSH is how I reach every server I run. Every regular server goes into ~/.ssh/config:

Host newsletter
  HostName 203.0.113.10
  User deploy
  IdentityFile ~/.ssh/id_ed25519

Then ssh newsletter works, and scp, rsync, and Git reuse the same alias. My key is ed25519 with a passphrase, and agent forwarding stays off.

The server migration I described in what I learned about agentic AI ran over SSH too. Codex had access to both machines and moved Apache, PHP, MySQL, and the cron jobs from the old server to the new one.

Gotcha: rebuild a server on the same IP and the next connection stops with a “REMOTE HOST IDENTIFICATION HAS CHANGED” warning, because the host key is new. When you know why it changed, drop the old key and connect again:

ssh-keygen -R 203.0.113.10

When you don’t know why, stop and find out before you connect.

SSH for developers covers keys, config, and copying files. The free SSH course goes further into hardening and rotation. For the other direction — apps you ssh into — see how to build an SSH app like superlogical.jobs.

Git and delta

I use the Git that ships with Xcode’s command line tools (git version 2.50.1 (Apple Git-155) on this Mac, checked September 2026), not a Homebrew one.

What I did change is the pager. delta shows diffs with syntax highlighting and line numbers. A few lines in ~/.gitconfig turn it on:

[core]
  pager = delta
[delta]
  line-numbers = true
  navigate = true
[interactive]
  diffFilter = delta --color-only
[merge]
  conflictstyle = diff3

navigate = true lets you jump between files in a long diff with n and N. diff3 adds the common ancestor to every merge conflict, which makes most of them obvious.

brew install git-delta

A post-commit hook also appends every commit to a plain text work log, grouped by date and project. How that log works and Git hooks explain the setup.

Gotcha: when several coding agents work in the same checkout at the same time, a bare git add . from one of them sweeps in files another one is still editing. I add and commit with explicit paths:

git add -- src/posts/zoxide.md
git commit -m "post: fix zoxide install line" -- src/posts/zoxide.md

The free Git course starts from zero if you need it.

gh

gh is GitHub’s official CLI. I use it for pull requests and for the repository settings the website makes hard to find.

gh pr list
gh pr checks 96
gh pr view 96 --web

When I put my software on GitHub I wanted pull requests off on those repositories. gh repo edit has no flag for that, but the API has a setting, and gh api gets you there:

gh api -X PATCH repos/flaviocopes/port-pilot -F has_pull_requests=false

Install and log in:

brew install gh
gh auth login

Gotcha: -F types the value, so false becomes a JSON boolean. Lowercase -f sends everything as a string, and a boolean setting won’t accept "false". I wrote about the whole release process in my software is on GitHub.

Wrangler

Wrangler is Cloudflare’s CLI. This site runs on Cloudflare Pages, so this is my deploy tool. I run it with npx wrangler so each project uses the version in its own package.json.

The commands I type most:

npx wrangler deploy
npx wrangler pages secret put RESEND_API_KEY --project-name flaviocopes
npx wrangler r2 object put flaviocopes-downloads/course-downloads/ai-fundamentals.pdf --file downloads/course-downloads/ai-fundamentals.pdf --remote

deploy pushes a Worker, in my case the tiny scheduled Worker that rebuilds this site after each post slot. pages secret put stores a secret for a Pages project, and r2 object put uploads a file to R2, the bucket behind my books and course downloads.

Gotcha: wrangler login gives you an OAuth token, and mine has no R2 scope. r2 object put failed until I created an API token with R2 permissions and passed it as CLOUDFLARE_API_TOKEN. That token lives in the Keychain, which is the next tool.

More in the Wrangler guide, and the Cloudflare products I actually use explains what each one does on this site.

security, the Keychain CLI

macOS ships security, a command line interface to the Keychain, and it’s my secret store for scripts. The Cloudflare API token from the previous section lives there. Storing a value:

security add-generic-password -a flavio -s cloudflare-api-token -w

Put -w last with nothing after it and the command prompts for the value, so the secret never lands in your shell history. Reading it back:

security find-generic-password -s cloudflare-api-token -w

A script wraps that in $(...) and passes the result as an environment variable to the command that needs it.

Gotcha: -w prints the secret to standard output, which is fine inside $(...) and bad inside a log. And over SSH, with no GUI session to unlock the Keychain, the same command fails with “User interaction is not allowed”.

If you want secrets shared across machines instead, I wrote a deep dive into 1Password Developer Environments.

gitleaks

gitleaks scans a repository for secrets, including the whole Git history. Before my software repositories went public, every one of them went through it:

gitleaks git . --redact

--redact masks whatever it finds, so a real key doesn’t end up in your scrollback.

brew install gitleaks

Gotcha: gitleaks looks for things shaped like keys and tokens. It doesn’t flag your email address, a /Users/flaviocopes/ path, or a real hostname in a config file, and those leak too. After gitleaks, I grep the history for them:

git log -p | grep -E "@flaviocopes.com|/Users/"

Older tutorials show gitleaks detect. Since version 8.19 the commands are gitleaks git for a repository and gitleaks dir for a plain folder.

Codex CLI

Codex is OpenAI’s coding agent. I spend most of my Codex time in the desktop app, and the CLI is what I use when I’m already in a shell or connected to a server over SSH.

cd ~/www/flaviocopes.com
codex

For a one-shot task that shouldn’t open the interactive interface, codex exec runs a prompt and exits:

codex exec "Find every script in package.json that nothing else references"

Install:

curl -fsSL https://chatgpt.com/codex/install.sh | sh

brew install --cask codex and npm install -g @openai/codex work too.

Gotcha: I have Codex installed twice on this Mac, once with the installer and once with npm, and which codex picks the npm one because Homebrew’s bin comes first in my PATH. If codex --version doesn’t match what you just installed, that’s why.

The complete guide to Codex covers the app, the CLI, permissions, and when I pick Codex over Cursor or Claude Code.

Claude Code

Claude Code is Anthropic’s agent for the terminal. I use it beside Codex. It’s also the one I ran on a throwaway VM to build a small web app that lives on that server.

cd ~/dev/things-cli
claude

Install:

curl -fsSL https://claude.ai/install.sh | bash

There’s a brew install --cask claude-code too, but that one doesn’t auto-update.

Gotcha: after a few days of sessions I found orphaned claude processes still running in the background. This alias in my config.fish cleans them up:

alias claude-cleanup='pkill -9 -f ".local/share/claude/versions/[0-9]"'

Claude Code is also chatty by default. How to make AI harnesses talk less has the setting that fixes that.

Cursor CLI

Cursor is my editor, and it also ships a terminal agent, agent. It was called cursor-agent for a while, and both names still work on my Mac.

I use it in scripts, where the flag that matters is -p, print mode. It runs one prompt without the interactive interface and exits:

agent -p "Which ports are listening on this machine, and which process owns each one?"

Install:

curl https://cursor.com/install -fsS | bash

Gotcha: --force lets the agent run commands without stopping to ask, and it also skips the permissions.deny list in .cursor/cli.json. One of my scripts runs the agent with --force, so it puts a fake git first in PATH that turns every mutating subcommand into a no-op. Whatever the agent must never do, enforce it outside the prompt.

Things CLI

Things is where my tasks live. I built Things CLI because my plans start in a terminal or in a conversation with a coding agent, and moving them into Things meant retyping every task.

things add "Call the accountant" --when tomorrow --tags work,money
things import newsletter-project.md
things list today --json

import turns a Markdown file with headings and bullets into a project with headings and tasks. --json exists so a coding agent can read the list back and check what it created.

npm install -g github:flaviocopes/things-cli

It needs macOS, Things 3, and Node.js 20 or newer.

Gotcha: it can’t delete anything, on purpose. If I want something gone, I do it in the app. Reading also goes through Apple events, so the first read command makes macOS ask whether your terminal may control Things.

I launched Things CLI has the full command list.

hey

HEY is my email, and hey-cli is 37signals’ official command line client for it. It has a full terminal interface, hey tui, and plain commands that speak JSON:

hey box view imbox --json
hey search "invoice"
hey thread read 123 --markdown

I wrote a deep dive into hey-cli when I set it up. Most of that post is about what a coding agent could do with its own email address and a narrow scope.

brew install --cask basecamp/tap/hey

Gotcha: the official script installer runs through a pipe, so it can’t ask you questions. It installs the binary, prints the next commands, and leaves the login to you. Run hey setup afterwards.

clitools

CLI Tools is a macOS app I built to keep one catalog of every command line tool installed on the Mac, from Homebrew, npm, Cargo, and folders like ~/.local/bin. This post is the curated list. The catalog is the honest one.

The companion CLI reads the same catalog:

clitools list --unused
clitools history gh

--unused shows the tools I never ran, according to my shell history. history shows how I called a tool in the past, which is usually the best example of how to use it.

It’s a Swift package, so you build it from source, with macOS 14 or later and Xcode 26 or a Swift 6.2 toolchain:

git clone https://github.com/flaviocopes/cli-tools.git
cd cli-tools
./Scripts/build-app.sh

Gotcha: it only knows what you typed. delta shows up as unused on my Mac because Git calls it, not me. The catalog also needs a clitools scan after you install something new.

I launched CLI Tools has the screenshots.

sendy

Sendy is the self-hosted newsletter software behind my weekly email, and sendy is a small Node CLI I wrote for its API. Every command prints JSON, so it composes with jq.

sendy campaign list --brand-id 1
sendy campaign get --brand-id 1 --campaign-id 755

The first lists the issues that went out. The second returns one issue as JSON, so I can check what the previous one covered before starting the next. sendy campaign draft then creates the new issue from an HTML file and a plain text file, and I finish it in Sendy’s web interface.

I haven’t published this one. It’s tied to my own Sendy installation.

Gotcha, of my own making: campaign create can send immediately. campaign draft never sends or schedules, so it’s the only one I call from a script.

What’s not here

Some tools I wrote about are missing because they aren’t on this Mac right now. The 1Password CLI is one: the secrets my scripts need go through the Keychain, so op isn’t installed today. ripgrep is another. Cursor bundles its own copy, and I never added a separate one.

The list moves. Something I use every day in September may be gone by spring, and clitools list --unused is how I find out.

Tagged: CLI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cli: