Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/gentle-poems-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@paddock/server": minor
---

`paddock service install | uninstall | status` — keep Paddock running in the background (#796)

Registers Paddock as a **per-user** background service: a launchd **LaunchAgent** on
macOS (`~/Library/LaunchAgents/net.edspencer.paddock.plist`), a **`systemd --user`** unit
on Linux (`~/.config/systemd/user/paddock.service`). `install` writes the unit and starts
it, `uninstall` stops and removes it, `status` reads real state back out of `launchctl
print` / `systemctl is-active` — including the port the unit was actually installed with.

It starts **at login, not at boot**, and every surface says so. That is not a limitation
to be worked around: on macOS your Claude login is a Keychain item, the login keychain is
unlocked by your account password at login, and a boot-time `LaunchDaemon` has no such
session — so `claude.credentials: host` and boot-time start are mutually exclusive. On
Linux, `install` prints the `loginctl enable-linger` you need to survive logout rather
than running it for you.

The generated unit sets **no** `PADDOCK_DATA_DIR`, so the service and a `paddock` typed
into a terminal are the same `~/.paddock` instance reached two ways; invokes `node`
explicitly by absolute path (launchd's stub `PATH` cannot find the bin's
`#!/usr/bin/env node`); restarts on crash but not on a clean exit; sits in
`<data-dir>/service` rather than `$HOME`; and carries `PATH` and nothing else in its
environment. Installing from an npx cache path is **refused** — those are hash-keyed and
prunable, so the unit would rot silently at some future login.

Also: `paddock start` is now an explicit synonym for the default, and the CLI parses a
leading verb before its flags. Bare `paddock` is unchanged, flags parse the same in every
position, and an unrecognised leading token is still an `unknown option` error.
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,14 @@ Needs **Node 22+**. First run downloads ~250 MB — Paddock drives Claude Code,
the Agent SDK ships a per-platform binary of that size; later runs reuse the npm
cache. For repeated use, `npm i -g @edspencer/paddock` beats bare `npx`.

The full flag set — any other argument is an error, and `--help` is the canonical
short-form reference:
There are two subcommands, and both are optional detail: bare `paddock` starts the
server, `paddock start` is the same thing said out loud, and
[`paddock service`](https://paddock.edspencer.net/guides/running-as-a-service/)
registers it as a background service that comes back at login. Anything else in
leading position is an error.

The full flag set — they parse the same after a subcommand as without one, and
`--help` is the canonical short-form reference:

| Flag | Purpose |
|---|---|
Expand All @@ -122,6 +128,24 @@ short-form reference:
Temporal's default frontend port, which is the usual collision. The failure is
loud — Paddock names the port and the flag and exits.)

### Always-on: your own laptop

`npx` is a terminal tab. To keep the same `~/.paddock` instance running in the
background instead:

```bash
npm i -g @edspencer/paddock
paddock service install # uninstall | status
```

A launchd **LaunchAgent** on macOS, a **`systemd --user`** unit on Linux. It starts
**at login, not at boot** — a per-user agent is what lets it read the Claude login
you already have, and on macOS a boot-time daemon structurally cannot (the login
Keychain is unlocked by your password *at login*). On Linux you also want
`loginctl enable-linger $USER`, or logging out stops it. Full detail, including
what the generated unit contains and what is still unverified:
[Keeping Paddock running on your laptop](https://paddock.edspencer.net/guides/running-as-a-service/).

### Always-on: Docker

For a server rather than a laptop, run the published image, point it at a data
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/claude-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,10 @@ export interface HostMcpCaveat {
* top-level `mcpServers` that applies everywhere, and a
* `projects[<absolute dir>].mcpServers` that applies only in that directory. The
* per-directory one is keyed by the LITERAL absolute path — not the `-`-encoded
* form the transcript folders use — and it is the scope a `--here` workspace
* hits, because `claude mcp add` without `--scope user` writes there.
* form the transcript folders use — and it is the scope a project linked at
* that path hits, because `claude mcp add` without `--scope user` writes there.
* (It used to say "a `--here` workspace"; that flag is gone as of #798, but the
* point survives it — a `path:` project reaches exactly the same entry.)
*
* {@link declared} is the third contributor and the one that is not the host's at
* all: paddock's own top-level `mcpServers:` config block (#691 step 6), resolved
Expand Down
133 changes: 132 additions & 1 deletion packages/server/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,80 @@ export class CliError extends Error {}

export const MIN_NODE_MAJOR = 22;

/** What `paddock service` can be asked to do. */
export const SERVICE_ACTIONS = ["install", "uninstall", "status"] as const;
export type ServiceAction = (typeof SERVICE_ACTIONS)[number];

/** The leading words {@link parseCommand} recognises. Anything else is a flag. */
export const VERBS = ["start", "service"] as const;

/**
* A parsed invocation: which verb, plus the flags that followed it.
*
* `action` is optional on `service` for exactly one reason — `paddock service
* --help`, where there is no action to name and printing usage is the whole
* request. It is `undefined` only when `opts.help` is true.
*/
export type Command =
| { verb: "start"; opts: CliOptions }
| { verb: "service"; action: ServiceAction | undefined; opts: CliOptions };

function isServiceAction(token: string): token is ServiceAction {
return (SERVICE_ACTIONS as readonly string[]).includes(token);
}

/**
* Split a leading verb off the argv, then parse the rest as flags.
*
* The dispatch is deliberately a check on `argv[0]` alone rather than a scan for
* the first non-flag token, and it happens BEFORE the flag loop. Two properties
* fall out of that, both of which matter more than the flexibility given up:
*
* - **Bare `paddock` is untouched.** No verb means the whole argv goes to
* {@link parseArgs} exactly as before, so the demo path cannot change
* behaviour, and an unrecognised leading token still produces `unknown
* option:` from the flag loop rather than a new and different error.
* - **Flags parse after a verb**, so `paddock start --port 7299` and
* `paddock service install --port 7299` both work, and the flag grammar is
* the same one in every position.
*
* A verb is only a verb in first position: `paddock --port start` is still a
* missing-value error, and `paddock start start` is still `unknown option`.
*/
export function parseCommand(argv: string[]): Command {
const [first, ...rest] = argv;

if (first === "service") {
const head = rest[0];
let action: ServiceAction | undefined;
if (head !== undefined && !head.startsWith("-")) {
// Catch a misspelled action here rather than letting the flag loop call
// it an "unknown option", which sends the reader looking for a flag.
if (!isServiceAction(head)) {
throw new CliError(
`unknown service action: ${head}\n` +
`Expected one of: ${SERVICE_ACTIONS.join(", ")}.\n` +
"Run `paddock service --help` for usage.",
);
}
action = head;
}
const opts = parseArgs(action === undefined ? rest : rest.slice(1));
// `--help` wins over a missing action: asking for usage is not a usage error.
if (action === undefined && !opts.help) {
throw new CliError(
`\`paddock service\` needs an action: ${SERVICE_ACTIONS.join(", ")}.\n` +
"Run `paddock service --help` for usage.",
);
}
return { verb: "service", action, opts };
}

if (first === "start") return { verb: "start", opts: parseArgs(rest) };

return { verb: "start", opts: parseArgs(argv) };
}

export function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
open: false,
Expand Down Expand Up @@ -130,7 +204,11 @@ export function explainListenError(err: unknown, host: string, port: string): st
export const USAGE = `paddock — run a Paddock instance locally

Usage
npx @edspencer/paddock [options]
npx @edspencer/paddock [options] start the server (the default)
paddock start [options] the same thing, said out loud
paddock service <install|uninstall|status>
run it in the background from login
(\`paddock service --help\`)

Options
-p, --port <port> HTTP/WS port (default 7233, or $PORT)
Expand Down Expand Up @@ -213,3 +291,56 @@ Notes
a routable interface wide open. See AUTH.md.

Docs: https://github.com/edspencer/paddock`;

export const SERVICE_USAGE = `paddock service — keep Paddock running in the background

Usage
paddock service install [options] register it and start it now
paddock service uninstall stop it and deregister it
paddock service status is it registered, is it running, where are the logs

Options (install only — recorded in the generated unit)
-p, --port <port> HTTP/WS port (default 7233)
--host <host> Bind address (default 127.0.0.1)
-d, --data-dir <path> Only if you want an instance SEPARATE from your
terminal one. Omitted by default on purpose, so
\`paddock service\` and a bare \`paddock\` are the same
~/.paddock instance reached two ways.
--verbose Record the server's own logs, not just warnings

At login, not at boot
This registers a per-USER service — a launchd LaunchAgent on macOS, a
\`systemd --user\` unit on Linux — so it runs as you, with your own Claude
login. That is not incidental: on macOS your Claude login is a Keychain item
that only a logged-in user session can read. A boot-time system daemon has no
such session and could not use it.

So Paddock starts when you LOG IN, not when the machine boots. After a
restart that nobody logs into, Paddock is not running. That is the design, not
a fault.

On Linux, a user service is also stopped when you log out. To keep it up:

loginctl enable-linger $USER

Where it lives
macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist
logs in <data-dir>/service/
Linux ~/.config/systemd/user/paddock.service
logs via journalctl --user -u paddock -f

Installed from npx?
\`service install\` refuses. An npx cache path is hash-keyed and npm may prune
it, so the unit would work until it silently didn't, at some future login.
Install properly first:

npm i -g @edspencer/paddock && paddock service install

A note on access
Paddock binds loopback with authentication off, which is right for a laptop.
A service is up for as long as you are logged in rather than as long as a
terminal tab, so that window is longer — but it is not wider: any local
process that could reach the port could already read the same Claude login as
you. Set PADDOCK_AUTH_MODE if you want a credential on it anyway.

Docs: https://github.com/edspencer/paddock`;
69 changes: 62 additions & 7 deletions packages/server/src/cli/paddock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,27 @@ import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
type CliOptions,
type Command,
CliError,
USAGE,
parseArgs,
SERVICE_USAGE,
parseCommand,
nodeVersionProblem,
explainListenError,
} from "./args.js";
import { runService, safeHomeDir } from "./service/index.js";

/** This module's own directory: `<…>/packages/server/dist/cli`. */
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
/**
* This module's own file and directory: `<…>/packages/server/dist/cli/`.
*
* `import.meta.url` is the module's REALPATH, which is why the service unit
* names this rather than `process.argv[1]` — npm installs a `bin` as a symlink,
* and a unit file pointing at `node_modules/.bin/paddock` would depend on that
* symlink surviving. (The same realpath-vs-argv[1] mismatch is what shipped a
* silent no-op to npm three times; see args.ts.)
*/
const entryScript = fileURLToPath(import.meta.url);
const moduleDir = path.dirname(entryScript);

/**
* Walk up from this module looking for `rel`, returning the containing dir.
Expand Down Expand Up @@ -144,17 +155,56 @@ function openBrowser(url: string): void {
}
}

/**
* `paddock service …` — register/inspect the background service (#796).
*
* Split out so `main` stays the start path. Note what it is handed: the
* interpreter and script by absolute path, and `packageRoot` — which is tested
* for npx's cache, because a unit file pointing into a prunable hash-keyed
* directory rots at some future login with nobody watching.
*/
function service(command: Extract<Command, { verb: "service" }>): void {
const { action, opts } = command;
// Unreachable: `parseCommand` only omits the action when `--help` was given,
// which `main` has already handled. Typed rather than asserted.
if (action === undefined) {
console.log(SERVICE_USAGE);
return;
}
try {
runService(action, opts, {
platform: process.platform,
nodePath: process.execPath,
scriptPath: entryScript,
packageRoot,
homeDir: safeHomeDir(),
...(process.env.PADDOCK_DATA_DIR !== undefined
? { envDataDir: process.env.PADDOCK_DATA_DIR }
: {}),
...(process.env.XDG_CONFIG_HOME !== undefined
? { xdgConfigHome: process.env.XDG_CONFIG_HOME }
: {}),
...(process.env.PATH !== undefined ? { pathEnv: process.env.PATH } : {}),
});
} catch (err) {
if (err instanceof CliError) fail(err.message);
if (err instanceof Error) fail(err.message);
throw err;
}
}

async function main(): Promise<void> {
let opts: CliOptions;
let command: Command;
try {
opts = parseArgs(process.argv.slice(2));
command = parseCommand(process.argv.slice(2));
} catch (err) {
if (err instanceof CliError) fail(err.message);
throw err;
}
const opts = command.opts;

if (opts.help) {
console.log(USAGE);
console.log(command.verb === "service" ? SERVICE_USAGE : USAGE);
return;
}
if (opts.version) {
Expand All @@ -165,6 +215,11 @@ async function main(): Promise<void> {
const problem = nodeVersionProblem(process.versions.node);
if (problem !== undefined) fail(problem);

if (command.verb === "service") {
service(command);
return;
}

addBundledBinsToPath();

// NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
Expand Down
Loading