The symptom: “Clash is on,” but the command line is not
On macOS, the failure mode is remarkably consistent. A Mihomo-based Clash GUI happily drives browser traffic through system proxy or a mixed port, while curl, git, brew, language package managers, and various CLI updaters still reach out directly. Nothing is “random”; most of those programs simply never consult the macOS proxy panel the way Safari does. They look for environment variables such as http_proxy, HTTPS_PROXY, and ALL_PROXY, or they ship their own configuration knobs. Until those signals exist in the same shell session that launches the command, you will keep seeing split behavior that feels like the proxy is “half broken.”
This guide assumes your subscription and rules already work in a browser or in the client’s built-in connectivity test. If the core itself cannot reach the internet, fix that first—no amount of zshrc editing will compensate for a dead node or a profile that blocks the update channel.
Why macOS treats Terminal differently from Safari
System proxy is a polite contract: well-behaved frameworks may copy those settings into their HTTP stacks. Many CLI tools are not obligated to do so. Some use their own TLS stacks, some speak plain TCP, and some respect only lowercase or only uppercase proxy variables depending on the library they linked against years ago. Clash on the desktop is therefore doing two different jobs at once: it can present an HTTP/SOCKS listener for apps that ask, and it can optionally install a TUN interface that captures traffic whether or not the app asked. If you have only enabled the first path, Terminal will keep feeling “direct” until you export variables or move to TUN.
For a broader picture of capture-based routing and when it beats chasing per-app settings, read the Clash TUN mode guide after you finish the checklist below—especially if you already suspect you will end up enabling TUN anyway.
A reproducible checklist (follow this order)
Debugging proxy issues by jumping straight to “paste a random snippet from a forum” wastes time. The order matters because later steps assume earlier ones are sane.
- Confirm which shell binary your Terminal is running and whether the session is login or non-login.
- Read the mixed port (or separate HTTP and SOCKS ports) from your Clash GUI; write them down.
- Export both lowercase and uppercase proxy variables for the current session and verify with
env | grep -i proxy. - Define
NO_PROXY/no_proxyso localhost and LAN targets stay direct. - Retest with
curl -vagainst a URL that clearly reflects your egress. - Check tool-specific overrides:
git config, npm/Yarn, and Homebrew quirks. - If exports keep multiplying, consider TUN for policy parity without duplicating env vars in every profile.
Step 1: Know your shell and which file actually loads
Apple ships zsh as the default user shell on modern macOS. That does not mean every Terminal profile loads ~/.zshrc in the order you expect. Login shells read ~/.zprofile (and historically ~/.zlogin) before interactive features; interactive non-login shells lean on ~/.zshrc. GUI Terminal windows vary by app and by the “login shell” checkbox in preferences. A common frustration is editing .zshrc while Terminal launches a login shell that never sourced it on startup, or the opposite.
Run echo "$SHELL" and ps -p $$ -o comm= to see what actually executes. If you want a one-line sanity check for login status, echo $0 often begins with - for login shells, but rely on your terminal emulator’s documentation when in doubt. The practical fix is boring but effective: keep a single block of proxy exports in a file you know is sourced—many people use ~/.zshrc for interactive work and symlink or source the same fragment from ~/.zprofile to cover GUI Terminal.app login sessions.
Step 2: Mixed port versus split HTTP and SOCKS
Most Clash-class clients expose a mixed port that speaks both HTTP and SOCKS on the same numeric port—frequently 7890 in documentation, but your local copy may differ if you changed defaults or imported a template. Some users instead enable distinct HTTP and SOCKS listeners. Your exports must match what the client actually listens on. A classic mistake is exporting http://127.0.0.1:7890 while the GUI moved the mixed listener to another port after an update, or while only SOCKS is enabled on that port.
Open the client’s settings screen and copy the numbers carefully. If you use SOCKS5 for tools that support it, ALL_PROXY=socks5://127.0.0.1:PORT is a common pattern; if you standardize on HTTP for simplicity, point both http_proxy and https_proxy at http://127.0.0.1:MIXED. Mismatching scheme and port produces errors that look like generic “connection refused” or “SSL handshake” noise.
Step 3: Export HTTP_PROXY, HTTPS_PROXY, and friends deliberately
Environment variables are the lingua franca of CLI networking. Because libraries disagree on case sensitivity, set both lowercase and uppercase forms unless you enjoy roulette:
http_proxyandHTTP_PROXYhttps_proxyandHTTPS_PROXYall_proxyandALL_PROXYwhen you want a SOCKS catch-all
Example for a typical mixed HTTP port on loopback (adjust the port):
export http_proxy="http://127.0.0.1:7890"
export https_proxy="http://127.0.0.1:7890"
export HTTP_PROXY="$http_proxy"
export HTTPS_PROXY="$https_proxy"
After exporting, run env | grep -i proxy and confirm there are no typos, stray quotes, or invisible characters pasted from a rich-text webpage. Then run curl -v https://example.com and read whether the CONNECT or proxy handshake targets 127.0.0.1. If curl still goes direct, you may be invoking a different curl binary than you think—which curl and curl --version settle that quickly.
Step 4: NO_PROXY and LAN exceptions
Once exports work, the next failure mode is over-proxying. Corporate intranets, home NAS devices, printer portals, and router admin pages often live on RFC1918 private addresses. If those destinations accidentally traverse your remote node, they may break even though “the internet” looks fine. Maintain a deliberate NO_PROXY list:
export no_proxy="127.0.0.1,localhost,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,*.local"
export NO_PROXY="$no_proxy"
Exact wildcard support depends on each library; when in doubt, enumerate critical hostnames. This step also explains mysterious cases where “everything works except my git server on a LAN IP”—your proxy variables were fine; the traffic simply should never have been proxied.
Step 5: Git, Homebrew, and other tools that ignore “the OS”
Git respects its own configuration. Even perfect shell exports can be overridden by repository or global config. Inspect git config --global --get http.proxy and git config --global --get https.proxy. For a quick experiment, you can set:
git config --global http.proxy http://127.0.0.1:7890
git config --global https.proxy http://127.0.0.1:7890
Remember to remove or adjust those entries when you change ports or when you switch to TUN-only workflows.
Homebrew sits on top of curl and git operations. In many setups, exporting the variables is enough, but institutional mirrors, custom ~/.curlrc files, or sandboxed CI environments can still surprise you. If brew appears to ignore exports, confirm you are not running it under a stripped environment such as sudo (which often drops user-specific environment unless configured otherwise) or a launchd job with a minimal env block.
Package managers for Node, Python, and Rust each introduce their own mirrors and TLS trust stores. When only one toolchain fails, suspect that toolchain—not Clash first.
Step 6: Read curl verbose output like a mini packet trace
curl -v is the fastest way to separate “DNS broke” from “proxy refused” from “upstream node timed out.” You do not need to paste secrets online to get value from it; redact hostnames if needed. Pair it with a small script that prints effective proxy variables so you can correlate “what I think I exported” with “what this shell actually sees.” If verbose output never mentions a proxy handshake, you are still in a direct path—return to shell startup files and launchd environment questions rather than tweaking YAML for the tenth time.
When to stop editing zshrc and enable TUN
Exports are lightweight when you live in one terminal profile and a handful of tools. They scale poorly when every IDE, test runner, background agent, and automation job needs the same variables. TUN mode pushes policy into the stack where stubborn binaries cannot ignore it, at the cost of installing a virtual interface and thinking harder about coexistence with other VPNs. If you already maintain a complex NO_PROXY matrix and you still see leaks, TUN is often the calmer long-term answer.
The TUN guide on this site walks through DNS alignment, fake-ip interactions, and desktop-specific footnotes—read it before you assume “TUN did nothing” when the real issue was resolver policy or an overlapping corporate client.
Common pitfalls that waste an evening
- Only lowercase or only uppercase variables, leaving a picky library blind.
- Wrong scheme: pointing
http_proxyat a SOCKS port without using asocks5h://form where required. - IPv6 literals or split stacks where the tool tries AAAA first and your proxy path differs from IPv4.
- Stale NO_PROXY entries that accidentally include a domain you now need proxied.
- Multiple Clash clients fighting for the same port after an upgrade.
Security and housekeeping
Proxy variables are powerful: anything running in your user session can read them, and sloppy copy-paste can leak credentials if you mistakenly embed a username or token into a URL. Prefer local loopback listeners bound to 127.0.0.1, keep your core updated, and treat subscription URLs like secrets. For curated install paths and documentation that stay aligned with maintained GUIs, use the Clash documentation hub; for license text and upstream source history, browse the official GitHub repositories separately from your day-to-day installer downloads.
Closing: make the behavior predictable, then move on
macOS Clash users who expect Terminal to mirror Safari without configuration are asking for two different operating system contracts to synchronize by magic. The fix is procedural: identify the shell, align ports, export http_proxy and HTTPS_PROXY consistently, carve NO_PROXY for LAN and loopback, validate with curl -v, then audit Git and brew. Compared with toggling random YAML keys whenever a single CLI misbehaves, that sequence turns “mysterious direct connection” into a bounded problem you can finish in one sitting.
When you want a maintained client, clear download channels, and room to grow into TUN without hunting nightly artifacts, Clash on the desktop still feels steadier than duct-taping five one-off proxy snippets across every tool you install. Pick a profile you understand, walk the checklist in order, and re-run a simple before-and-after test: one domestic host that must stay direct, one international host that must show proxy egress, and one LAN IP that must bypass the tunnel. If all three behave, you can close the terminal and get back to real work.