npx @deepseek-ai/dsh web is fine for a quick session, but a service you want running at login needs three things: it starts when you log in, it comes back if it crashes, and you can read its logs without hunting. brew services gives you all three, and with a local tap the service definition lives in a formula you can version.
This post is the setup actually running on this machine (Apple Silicon, Homebrew at /opt/homebrew).
Why a tap at all
brew services start <formula> needs a formula. A tap is just a directory with Formula/*.rb in it — a local tap is that directory sitting on disk instead of GitHub. Same formula, no publishing step. The Homebrew convention puts taps in $(brew --prefix)/Library/Taps/<owner>/homebrew-<name>/, so this machine has:
/opt/homebrew/Library/Taps/<you>/homebrew-dsh/
├── Formula/dsh.rb
├── dsh-web # the wrapper this formula installs
└── README.md
The formula
The wrapper script ships inside the tap, so the formula points at it with a file:// URL and a pinned SHA-256:
class Dsh < Formula
desc "DeepSeek Harness web UI service wrapper"
homepage "https://github.com/deepseek-ai/deepseek-harness"
version "0.1.2"
license "MIT"
# Local tap: fetch the wrapper straight from this tap directory.
# After editing dsh-web, refresh the hash: shasum -a 256 dsh-web
url "file://#{__dir__}/../dsh-web", using: :nounzip
sha256 "8971a8894d71119dfc3bc0a15f146558932681a89928aab1e833b502c1070007"
depends_on :macos
def install
bin.install "dsh-web"
end
service do
run [opt_bin / "dsh-web"]
keep_alive true
working_dir HOMEBREW_PREFIX
log_path var / "log/dsh-web.log"
error_log_path var / "log/dsh-web.error.log"
process_type :interactive
end
test do
system "bash", "-n", bin / "dsh-web"
assert_path_exists bin / "dsh-web"
end
end
The service do block is the whole point: keep_alive true makes launchd restart it on crash, working_dir HOMEBREW_PREFIX gives it a sane cwd, and log_path/error_log_path land in /opt/homebrew/var/log/.
The wrapper: the part nobody tells you about
The subtle problem with launchd is that it does not load your shell profile — the environment is nearly empty, and node/dsh are not on PATH. A naive run [opt_bin/"dsh-web"] wrapper that calls npx or dsh by bare name fails silently at boot.
The wrapper on this machine resolves both executables explicitly, preferring the active Node version manager (znvm), then nub's shim, then Homebrew's node:
node_bin="$(find_bin "${DSH_NODE:-}" \
"$znvm_default" "$znvm_newest" \
"$HOME/.nub/node-shim/node" \
"/opt/homebrew/bin/node" "/usr/local/bin/node" "/usr/bin/node" || true)"
if [[ -n "$node_bin" ]]; then
export PATH="$(dirname "$node_bin"):$PATH"
fi
dsh_bin="$(find_bin "${DSH_BIN:-}" \
"$HOME/.znvm/npm/bin/dsh" \
"$HOME/.npm-global/bin/dsh" \
"/opt/homebrew/bin/dsh" "/usr/local/bin/dsh" || true)"
exec "$dsh_bin" web "$@"
Both paths are overridable via DSH_NODE/DSH_BIN if a machine differs. One comment in the script is worth repeating: the .nub shim resolves to the active Node version in an interactive shell but can fall back to an old one under launchd's clean environment — so the order matters, and znvm's active version is preferred first.
Install, start, verify
brew install <you>/dsh/dsh
brew services start dsh
brew services start writes a launchd agent — ~/Library/LaunchAgents/homebrew.mxcl.dsh.plist — with RunAtLoad and KeepAlive set, plus the program arguments, log paths and working directory from the formula's service block.
Then verify it is actually up:
brew services list | grep dsh # started
launchctl list | grep dsh # a PID in the first column
tail -f /opt/homebrew/var/log/dsh-web.log
The log on this machine says exactly what you want to see:
dsh web: http://127.0.0.1:3080
Day-to-day
brew services stop dsh
brew services restart dsh
brew services list
To remove it entirely: brew services stop dsh, brew uninstall dsh, and delete the tap directory.
Moving to another machine
A local tap becomes a remote one the moment you push the directory to GitHub. New machine: brew tap your-name/dsh and the same brew services start dsh — the formula, the wrapper and the service definition all travel together. That is the upgrade path from "works on my machine" to "works on every machine".