# The CLI tools I use every day

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-25 | Topics: [CLI](https://flaviocopes.com/tags/cli/) | Canonical: https://flaviocopes.com/cli-tools-i-use/

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](https://flaviocopes.com/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](https://flaviocopes.com/courses/terminal/) and the [Command Line Guide](https://flaviocopes.com/cli/) 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:

```ini
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.

```bash
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](https://flaviocopes.com/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:

```fish
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:

```bash
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](https://flaviocopes.com/fish-shell/) 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:

```bash
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`:

```fish
zoxide init fish | source
```

Install both, because `zi` needs fzf:

```bash
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](https://flaviocopes.com/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.

```bash
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:

```bash
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.

```bash
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](https://flaviocopes.com/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`.

```bash
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:

```bash
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](https://flaviocopes.com/jsonl/).

[The jq command](https://flaviocopes.com/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`:

```text
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](https://flaviocopes.com/agentic-ai-lessons/) 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:

```bash
ssh-keygen -R 203.0.113.10
```

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

[SSH for developers](https://flaviocopes.com/ssh-for-developers/) covers keys, config, and copying files. The free [SSH course](https://flaviocopes.com/courses/ssh/) goes further into hardening and rotation. For the other direction — apps you ssh into — see [how to build an SSH app like superlogical.jobs](https://flaviocopes.com/ssh-apps/).

## 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:

```ini
[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.

```bash
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](https://flaviocopes.com/log-git-commits-plain-text/) and [Git hooks](https://flaviocopes.com/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:

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

The free [Git course](https://flaviocopes.com/courses/git/) 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.

```bash
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:

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

Install and log in:

```bash
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](https://flaviocopes.com/software-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:

```bash
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](https://flaviocopes.com/cloudflare-wrangler/), and [the Cloudflare products I actually use](https://flaviocopes.com/cloudflare-products-i-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:

```bash
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:

```bash
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](https://flaviocopes.com/1password-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:

```bash
gitleaks git . --redact
```

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

```bash
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:

```bash
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.

```bash
cd ~/www/flaviocopes.com
codex
```

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

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

Install:

```bash
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](https://flaviocopes.com/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.

```bash
cd ~/dev/things-cli
claude
```

Install:

```bash
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:

```fish
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](https://flaviocopes.com/concise-coding-agents/) 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:

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

Install:

```bash
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.

```bash
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.

```bash
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](https://flaviocopes.com/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:

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

I wrote [a deep dive into hey-cli](https://flaviocopes.com/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.

```bash
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:

```bash
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:

```bash
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](https://flaviocopes.com/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.

```bash
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.
