Goal
Distribute Paddock via Homebrew, as the permanent, always-on install path.
The two front doors are meant to feel different:
|
npx @edspencer/paddock |
brew install + brew services |
| intent |
ad hoc, try it, per-directory |
installed on the system, always up |
| lifetime |
foreground, Ctrl-C |
daemon, survives login/reboot |
| workspace |
--here opens the cwd |
one home instance |
| upgrade |
re-run npx |
brew upgrade |
Nothing here is started yet — this issue is the design record and the implementation
plan. See "Decisions needed" at the bottom.
1. Own tap, not homebrew-core
homebrew-core gates new formulae on notability
(policy), and self-submission is the
strict tier — 90 forks, 90 watchers, or 225 stars. Paddock today has 2 stars, 0 forks,
0 watchers. Not close, and not worth optimising for.
So: edspencer/homebrew-tap → brew install edspencer/tap/paddock.
Homebrew explicitly endorses this route ("software that does not meet the official
criteria can generally be maintained in a third-party tap"). brew upgrade,
brew services, and brew uninstall all behave identically. The only cost is a longer
install command. Revisit core if the stars ever arrive.
2. The formula
The current idiom for npm CLIs points
url at the registry tarball we already publish. Verified against the real
typescript and prettier formulae in homebrew-core — the install block is
byte-identical to theirs.
class Paddock < Formula
desc "Persistent, web-based home for your Claude Code sessions"
homepage "https://github.com/edspencer/paddock"
url "https://registry.npmjs.org/@edspencer/paddock/-/paddock-0.59.1.tgz"
sha256 "4e1edc66aee44e1114f1507e20344050365c629b528058d4fddc5ee68e2b91c0"
license "MIT"
depends_on "node"
def install
system "npm", "install", *std_npm_args
bin.install_symlink libexec.glob("bin/*")
end
service do
run [opt_bin/"paddock", "--port", "7373"]
keep_alive true
log_path var/"log/paddock.log"
error_log_path var/"log/paddock.log"
environment_variables PATH: std_service_path_env
end
test do
assert_match version.to_s, shell_output("#{bin}/paddock --version")
end
end
The sha256 above is real (computed from the live 0.59.1 tarball) but will need
regenerating per release — see §7.
The npx work (#637/#638/#640) paid for nearly all of this: because we already synthesize
a single self-contained package with a working bin, the formula is the boring
three-line install.
3. VERIFIED: ignore_scripts is safe for us
std_npm_args disables npm lifecycle scripts by default — Homebrew's deliberate
supply-chain hardening. Paddock needs a ~250 MB Claude SDK platform binary that arrives
via an optional dependency, so this was the main thing that could have made brew
impossible. Tested:
$ npm install --ignore-scripts @edspencer/paddock@0.59.1
added 320 packages in 11s
$ ls node_modules/@anthropic-ai/
claude-agent-sdk claude-agent-sdk-linux-x64 sdk # platform binary present
$ ./node_modules/.bin/paddock --version
0.59.1
$ du -sh node_modules
407M
It works. The platform binary is a plain optionalDependency with no postinstall, so
ignoring scripts costs nothing functional. npm resolves the right per-platform package on
each machine, so darwin-arm64 users get theirs.
One casualty: packages/server/scripts/install-notice.mjs (the "this is 250 MB"
preinstall warning) never fires under brew. That's acceptable — arguably better, since
brew install prints its own progress and the notice existed to explain npx's silence.
Worth a comment in make-npm-package.mjs so nobody later "fixes" the notice by making it
load-bearing.
4. Default port: move off 4000
4000 is a bad default. It's IANA-registered to terabase, and in practice it's Phoenix's
and Jekyll's default — a collision on a dev machine is likely, and it's much worse for an
always-on service than for a foreground process you can just Ctrl-C and rerun.
Proposed: 7373. Checked against the full IANA registry (6,350 genuinely-assigned
ports, parsed from the official CSV — filtering out Reserved/Unassigned placeholder
rows, which naive parsing counts as taken):
- IANA-unassigned ✅
- below 32768, so below the Linux ephemeral floor — a port inside the ephemeral range can
already be held by a random outbound connection, causing intermittent bind failures ✅
- no popular collision (only an obscure ATTO config tool) ✅
- memorable ✅
Alternatives that also pass every check: 23232, 27272.
Rejected: 8123 (ClickHouse HTTP, Home Assistant), 7233 (Temporal), 11434 (Ollama),
26000 (Quake), 4747/5417/19191 (IANA-assigned).
Open question: change the default globally, or only in the service block? Globally is
cleaner and breaks bookmarks/reverse-proxy configs for existing users — but the user base
is tiny right now, so this is the cheapest it will ever be. Leaning global.
5. Service design — the actual work
It's launchd on macOS, not systemctl
brew services start paddock writes ~/Library/LaunchAgents/homebrew.mxcl.paddock.plist
on macOS. You get systemd/systemctl on Linux, where
brew services requires systemd.
Same command either way.
The daemon has no cwd — but the defaults already work
--here is per-directory and consent-gated by the flag. A daemon has no meaningful cwd.
Paddock already has the right answer built in: with no --here, dataDir defaults to
~/.paddock and projectsRoot falls out as ~/.paddock/projects
(packages/server/src/config.ts:774).
So the service block should set no PADDOCK_DATA_DIR. Then
brew services start paddock and a bare paddock in a terminal land on the same
instance — one home instance, two ways to reach it. Note this deliberately departs from
the Homebrew convention of var/paddock; for a per-user agent, HOME-relative is more
coherent and matches what npx users already have.
🔴 Credentials under launchd — the biggest unknown
On macOS, Claude Code stores credentials in the Keychain. A user-level launch agent
should reach the login keychain once unlocked, but this is untested and the failure
mode is nasty: Paddock boots fine, the UI loads, and every chat fails.
This must be tested on a real Mac before the tap is announced. If keychain access
doesn't work from a launch agent, options are environment_variables carrying
ANTHROPIC_API_KEY, or documenting claude setup-token + a token file. Note
warnIfNoCredentials (cli/paddock.ts) prints to stderr, which under launchd goes to
error_log_path where nobody looks — the service path may need a louder signal, e.g.
surfacing "no credentials" in the UI rather than only in a log.
🔴 An always-on unauthenticated agent is a different threat model
Auth defaults to none on a loopback bind, which is the right call for npx in a
terminal you Ctrl-C when you're done. A daemon that is up from login to shutdown, exposing
filesystem-wide agent capability on an unauthenticated local port, is a materially larger
surface: any local process — or any other user on a shared machine — can drive an agent
with your credentials.
The bind-safety guard (#435) still prevents non-loopback + auth: none, so this is not a
hole so much as a posture that should be chosen deliberately rather than inherited.
Proposal: service mode should default to token auth, with the token generated on
first service start and printed by brew services info paddock or written to
~/.paddock/service-token. Needs its own design pass — flagging as design-needed.
brew upgrade does not restart a running service
Users need brew services restart paddock. Must be a caveat in the formula (def caveats)
and in the docs, or people will run an old build for weeks and report fixed bugs.
6. No bottles → a real 400 MB install
For a personal tap nobody prebuilds binaries, so brew install runs an actual
npm install on the user's machine: ~400 MB, a minute or two. Acceptable; just be honest
about it in the docs. Building bottles in CI and attaching them to GitHub releases is
possible but not worth it until someone complains.
7. Keeping the formula current
Two options for bumping url + sha256 on each release:
- Push directly — add a step to
release.yml after the provenance check that
checks out the tap and commits the bump.
brew bump-formula-pr — opens a PR against the tap instead.
Given it's our own tap and the release is already automated, (1) is simpler.
⚠️ Auth gotcha: our npm publish uses OIDC trusted publishing, which does not extend
to pushing to a second repo. This needs a PAT with repo scope on the tap, or a GitHub App
token. Don't assume the existing release auth covers it.
8. Prerequisite: the licence
Homebrew formulae need a license stanza that matches reality, and Paddock currently has
no LICENSE file and no license field anywhere. Tracked in #674 — that must land
before the tap is published.
Decisions needed
Implementation checklist (once decided)
Goal
Distribute Paddock via Homebrew, as the permanent, always-on install path.
The two front doors are meant to feel different:
npx @edspencer/paddockbrew install+brew services--hereopens the cwdbrew upgradeNothing here is started yet — this issue is the design record and the implementation
plan. See "Decisions needed" at the bottom.
1. Own tap, not homebrew-core
homebrew-core gates new formulae on notability
(policy), and self-submission is the
strict tier — 90 forks, 90 watchers, or 225 stars. Paddock today has 2 stars, 0 forks,
0 watchers. Not close, and not worth optimising for.
So:
edspencer/homebrew-tap→brew install edspencer/tap/paddock.Homebrew explicitly endorses this route ("software that does not meet the official
criteria can generally be maintained in a third-party tap").
brew upgrade,brew services, andbrew uninstallall behave identically. The only cost is a longerinstall command. Revisit core if the stars ever arrive.
2. The formula
The current idiom for npm CLIs points
urlat the registry tarball we already publish. Verified against the realtypescriptandprettierformulae in homebrew-core — theinstallblock isbyte-identical to theirs.
The sha256 above is real (computed from the live 0.59.1 tarball) but will need
regenerating per release — see §7.
The npx work (#637/#638/#640) paid for nearly all of this: because we already synthesize
a single self-contained package with a working
bin, the formula is the boringthree-line install.
3. VERIFIED:
ignore_scriptsis safe for usstd_npm_argsdisables npm lifecycle scripts by default — Homebrew's deliberatesupply-chain hardening. Paddock needs a ~250 MB Claude SDK platform binary that arrives
via an optional dependency, so this was the main thing that could have made brew
impossible. Tested:
It works. The platform binary is a plain optionalDependency with no postinstall, so
ignoring scripts costs nothing functional. npm resolves the right per-platform package on
each machine, so darwin-arm64 users get theirs.
One casualty:
packages/server/scripts/install-notice.mjs(the "this is 250 MB"preinstall warning) never fires under brew. That's acceptable — arguably better, since
brew installprints its own progress and the notice existed to explain npx's silence.Worth a comment in
make-npm-package.mjsso nobody later "fixes" the notice by making itload-bearing.
4. Default port: move off 4000
4000 is a bad default. It's IANA-registered to
terabase, and in practice it's Phoenix'sand Jekyll's default — a collision on a dev machine is likely, and it's much worse for an
always-on service than for a foreground process you can just Ctrl-C and rerun.
Proposed: 7373. Checked against the full IANA registry (6,350 genuinely-assigned
ports, parsed from the official CSV — filtering out
Reserved/Unassignedplaceholderrows, which naive parsing counts as taken):
already be held by a random outbound connection, causing intermittent bind failures ✅
Alternatives that also pass every check: 23232, 27272.
Rejected: 8123 (ClickHouse HTTP, Home Assistant), 7233 (Temporal), 11434 (Ollama),
26000 (Quake), 4747/5417/19191 (IANA-assigned).
Open question: change the default globally, or only in the service block? Globally is
cleaner and breaks bookmarks/reverse-proxy configs for existing users — but the user base
is tiny right now, so this is the cheapest it will ever be. Leaning global.
5. Service design — the actual work
It's launchd on macOS, not systemctl
brew services start paddockwrites~/Library/LaunchAgents/homebrew.mxcl.paddock.pliston macOS. You get systemd/
systemctlon Linux, wherebrew services requires systemd.
Same command either way.
The daemon has no cwd — but the defaults already work
--hereis per-directory and consent-gated by the flag. A daemon has no meaningful cwd.Paddock already has the right answer built in: with no
--here,dataDirdefaults to~/.paddockandprojectsRootfalls out as~/.paddock/projects(
packages/server/src/config.ts:774).So the service block should set no
PADDOCK_DATA_DIR. Thenbrew services start paddockand a barepaddockin a terminal land on the sameinstance — one home instance, two ways to reach it. Note this deliberately departs from
the Homebrew convention of
var/paddock; for a per-user agent, HOME-relative is morecoherent and matches what npx users already have.
🔴 Credentials under launchd — the biggest unknown
On macOS, Claude Code stores credentials in the Keychain. A user-level launch agent
should reach the login keychain once unlocked, but this is untested and the failure
mode is nasty: Paddock boots fine, the UI loads, and every chat fails.
This must be tested on a real Mac before the tap is announced. If keychain access
doesn't work from a launch agent, options are
environment_variablescarryingANTHROPIC_API_KEY, or documentingclaude setup-token+ a token file. NotewarnIfNoCredentials(cli/paddock.ts) prints to stderr, which under launchd goes toerror_log_pathwhere nobody looks — the service path may need a louder signal, e.g.surfacing "no credentials" in the UI rather than only in a log.
🔴 An always-on unauthenticated agent is a different threat model
Auth defaults to
noneon a loopback bind, which is the right call fornpxin aterminal you Ctrl-C when you're done. A daemon that is up from login to shutdown, exposing
filesystem-wide agent capability on an unauthenticated local port, is a materially larger
surface: any local process — or any other user on a shared machine — can drive an agent
with your credentials.
The bind-safety guard (#435) still prevents non-loopback +
auth: none, so this is not ahole so much as a posture that should be chosen deliberately rather than inherited.
Proposal: service mode should default to token auth, with the token generated on
first service start and printed by
brew services info paddockor written to~/.paddock/service-token. Needs its own design pass — flagging asdesign-needed.brew upgradedoes not restart a running serviceUsers need
brew services restart paddock. Must be a caveat in the formula (def caveats)and in the docs, or people will run an old build for weeks and report fixed bugs.
6. No bottles → a real 400 MB install
For a personal tap nobody prebuilds binaries, so
brew installruns an actualnpm installon the user's machine: ~400 MB, a minute or two. Acceptable; just be honestabout it in the docs. Building bottles in CI and attaching them to GitHub releases is
possible but not worth it until someone complains.
7. Keeping the formula current
Two options for bumping
url+sha256on each release:release.ymlafter the provenance check thatchecks out the tap and commits the bump.
brew bump-formula-pr— opens a PR against the tap instead.Given it's our own tap and the release is already automated, (1) is simpler.
to pushing to a second repo. This needs a PAT with repo scope on the tap, or a GitHub App
token. Don't assume the existing release auth covers it.
8. Prerequisite: the licence
Homebrew formulae need a
licensestanza that matches reality, and Paddock currently hasno LICENSE file and no
licensefield anywhere. Tracked in #674 — that must landbefore the tap is published.
Decisions needed
edspencer/homebrew-tap— a new public repo under the personal accountImplementation checklist (once decided)
edspencer/homebrew-tapwithFormula/paddock.rbbrew installon macOS and Linuxbrew services starton a real Mac — verify chats actually work (keychain)def caveatscovering: restart-after-upgrade, where data lives, how to reach the UI