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