zaps
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Fail
- execSync — Synchronous shell command execution in scripts/build-native.ts
- process.env — Environment variable access in scripts/build-native.ts
- execSync — Synchronous shell command execution in scripts/build.ts
- process.env — Environment variable access in scripts/build.ts
- process.env — Environment variable access in scripts/prepare-npm-platform-packages.ts
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Zero Ass Pain Setup - a tmux dev-environment orchestrator: define services, deps, tasks & layout in one config, with ready detection, crash recovery, and a keyboard-driven TUI.
⚡ Painless project setup ⚡
Prerequisites
- tmux (>= 3.5a)
Install
npm (Node >= 22)
npm install -g @bosdev/zaps
# or: pnpm add -g @bosdev/zaps
The package name is @bosdev/zaps; the installed command is zaps. The native
binary for your platform (linux/darwin, x64/arm64) is installed
automatically as an optional dependency and used when present; otherwise the
bundled Node build runs.
Prebuilt binary (no Node required)
Download the asset for your platform from the
latest release
(zaps-linux-x64, zaps-linux-arm64, zaps-darwin-x64, zaps-darwin-arm64),
then make it executable and put it on your PATH:
# Example: Linux x64
curl -fsSL -o zaps https://github.com/boshold/zaps/releases/latest/download/zaps-linux-x64
chmod +x zaps
sudo mv zaps /usr/local/bin/zaps
Optionally verify the download against SHA256SUMS from the same release:
curl -fsSL -O https://github.com/boshold/zaps/releases/latest/download/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
Quick Start
# Inside a tmux session:
zaps init # Scaffold .zaps.mts config
# Edit .zaps.mts to define your services
zaps # Launch
ZAPS must be run from inside a tmux session.
Commands
Lifecycle
| Command | Description |
|---|---|
zaps / zaps up |
Smart default: attach if session running, else create + start + attach TUI |
zaps up -d |
Create session and start services without attaching TUI (detached) |
zaps down |
Stop all services and destroy session |
Service Operations
| Command | Description |
|---|---|
zaps start [service...] |
Start service(s). All if omitted |
zaps stop [service...] |
Stop service(s). All if omitted |
zaps restart [service...] |
Restart service(s). All if omitted |
Query
| Command | Description |
|---|---|
zaps ps |
List services with state, ports, URL. --json |
zaps ls |
List active sessions. --json |
zaps inspect <service> |
Show service details. --json |
Tasks & Logs
| Command | Description |
|---|---|
zaps run <task> |
Run a task. Streams output. --json |
zaps tasks |
List configured tasks. --json |
zaps logs [service...] |
Dump log buffer. -f to stream live. --tail <n> (default 100) |
zaps events |
Stream daemon events as ndjson. --filter <type> |
Config & Setup
| Command | Description |
|---|---|
zaps config |
Validate and print resolved config. --json, --toon, --path |
zaps reload |
Reload config for the running session (CLI form of the dashboard c) |
zaps init |
Scaffold a starter .zaps.mts config |
zaps attach |
Attach TUI to a running session (use -s to choose one) |
zaps daemon start|stop|status|ping |
Daemon management (ping checks the daemon is responsive) |
Selecting a session & output format
-s, --session <id\|name>is a global flag (place it before the command):zaps -s my-app attach,zaps -s my-app down. It matches by exact id, exact name, then id/name prefix. When omitted, ZAPS resolves the session for the current directory; if several match it lists them and asks you to disambiguate.- Output format — query/service commands print a human table by default. Pass
--json(pretty JSON) or--toon(TOON) for machine output, or setZAPS_FORMAT=json|toonto make it the default. Coding agents (detected viaCLAUDECODE/CURSOR_TRACE_DIR) default to TOON automatically.
Configuration
Config Discovery
ZAPS walks up from the current directory looking for these filenames (first match wins):
.local.zaps.mts— local override (gitignore this)local.zaps.mts.local.zaps.tslocal.zaps.ts.zaps.mts— primary config.zaps.ts
Minimal Config
import type { Library } from "@bosdev/zaps";
export function config({ define }: Library) {
return define({
services: {
api: {
start: "npm run dev",
ready: { port: 3000 },
},
},
});
}
The config function receives the Library object, which groups everything into
namespaces: define, find, cli, task, service, browser, and node.
Destructure just the pieces you use. See Library API for the full
reference. The function may be async — declare itexport async function config(lib) { … } (the result is awaited).
Full Config Reference
import type { Library } from "@bosdev/zaps";
export function config({ define }: Library) {
return define({
name: "my-app",
cwd: "./packages/app",
services: {
db: {
docker: {
service: "postgres",
file: "./docker-compose.yml",
build: true,
forceRecreate: false,
renewVolumes: false,
removeOrphans: true,
pull: "missing",
noDeps: false,
},
restart: { maxRetries: 5, backoff: 2000 },
},
api: {
start: "npm run dev:api",
ready: { output: /listening on port/ },
dependsOn: ["db"],
cwd: "./packages/api",
env: { NODE_ENV: "development" },
url: "http://localhost:4000",
},
web: {
start: "npm run dev:web",
ready: { port: true },
dependsOn: ["api"],
url: (ctx) => `http://localhost:${ctx.services.web.port}`,
onOutput: (line) => {
if (/error/i.test(line)) notifySlack(line);
},
},
worker: {
run: "npm run worker",
detached: true,
flags: { start: false },
},
},
tasks: {
migrate: {
name: "Run migrations",
description: "Apply pending database migrations",
commands: "npx prisma migrate deploy",
cwd: "./packages/api",
shortcut: "m",
},
seed: {
name: "Seed database",
commands: ["npx prisma db seed", "npx prisma db fixtures"],
dependsOn: ["migrate"],
popup: true,
},
},
layout: {
direction: "columns",
children: [
{ pane: "@tui", size: "30" },
{
direction: "rows",
children: [
{ pane: "api", size: "50" },
{ pane: "web", size: "50" },
],
},
],
},
hooks: {
onBeforeStart: () => console.log("Running setup"),
onStart: () => console.log("All services started"),
onStop: () => console.log("Shutting down"),
},
ui: {
icons: "nerd",
notifications: "osc9",
failOutput: "overlay",
wideThreshold: 100,
task: { defaultMode: "background", popupPicker: false },
},
});
}
See UI Config for the full ui block reference.
Project Options
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
dir name | Project name shown in TUI |
cwd |
string | (ctx: CwdContext) => string |
invoke dir | Project working directory |
cwd
By default, projectDir is the directory where zaps was invoked. Use cwd to override this — useful when sharing a config across multiple projects:
workspace/
customer/
.zaps.mts ← shared config
customer-1/ ← run `zaps` here
customer-2/ ← or here
String — resolved relative to the config file's directory:
cwd: "./customer-1";
Function — receives { configDir, invokeDir } for dynamic resolution:
cwd: ({ invokeDir }) => invokeDir; // already the default
find.up(filename, opts?) — walk upward from the invoke directory to the first
directory containing filename, and use that as cwd. Handy for monorepos where you
run zaps from a nested package but want the project root:
export function config({ define, find }) {
return define({
cwd: find.up("package.json"),
services: {
/* … */
},
});
}
Options:
stopAt— stop the walk at a boundary: an absolute path, or the literal"config"
to stop at the config file's directory.cwd: find.up("package.json", { stopAt: "config" }).orFatal— the message used iffilenameis never found:cwd: find.up("Cargo.toml", { orFatal: "Run zaps inside a Rust crate" }).
find.up always returns a resolver (never null). If the file isn't found, it throws aConfigError when the config loads — there's no ?? cli.fatal() to write. See
Error Model.
Node Built-ins
The Library object includes a node namespace with common Node.js modules (path, fs, process, url, os, child_process) so configs don't need raw import statements:
export function config({ define, node }: Library) {
return define({
cwd: ({ configDir }) => node.path.join(configDir, "backend"),
services: {
api: {
start: "npm run dev",
env: { HOME: node.process.env.HOME ?? "" },
},
},
});
}
Config Loading & Reload
Config files (.zaps.ts / .zaps.mts and their local. variants) are evaluated
with jiti. Each load re-evaluates the entire
import graph — the entry file plus every relative helper/env file it imports — so
you can split config across modules and a reload picks up edits to any of them:
// helper.mts
export const apiPort = 3000;
// .zaps.mts
import { apiPort } from "./helper.mts";
export function config(z) {
/* use apiPort */
}
Caveats (consequences of jiti's per-load CJS transform):
- No ESM live bindings — exports are snapshotted at load time.
- Module identity changes per load: values from one load are not
===/instanceof
identical to the next. Don't rely on cross-reload object identity. node_modulesreached from a config are re-evaluated on each load. If config load
becomes slow because of a heavy dependency, that cost is per reload.
Reloading a running session is validate-then-swap: the new config is loaded and
validated first, and only swapped in if it parses cleanly. An invalid edit never tears
down the running session — the old config stays live and the load error is reported.
When ZAPS detects the config file changed on disk, the TUI header shows aconfig changed — press c to reload hint; press c on the dashboard (while no
service is mid-operation) to apply it.
Library API
The Library object passed to your config function groups everything into
namespaces. Destructure what you need: config(({ define, find, cli, service }) => …).
| Namespace | Member | Description |
|---|---|---|
define |
(config) => config |
Validate and return the project config. Throws ConfigError on bad config. |
find |
up(filename, opts?) |
Build a cwd resolver that walks upward (see cwd). |
cli |
fatal(message, opts?) |
Abort config eval by throwing a ConfigError. Never returns. |
cli |
warn / info / success(message) |
Emit a notice (stderr in the CLI; a TUI toast during a daemon reload). |
task |
run(key) |
Run a named task. Only available inside service hooks. |
service |
start / stop / restart(name) |
Control a service. Only available inside service hooks. |
service |
isRunning(name) |
Whether a service is currently ready. Hooks only. |
browser |
open(url) |
Open a URL in the default browser. |
node |
path / fs / process / … |
Common Node.js modules, no import needed. |
task.* and service.* are only wired up while a session is running, so call them
from hooks (e.g. onReady), not at the top level of config. Calling them too early
throws a clear error.
export function config({ define, cli, service }) {
return define({
services: {
api: {
start: "npm run dev",
ready: { port: 3000 },
env: { API_KEY: process.env.API_KEY ?? cli.fatal("API_KEY is required") },
},
worker: {
run: "npm run worker",
onReady: () => service.restart("api"),
},
},
});
}
Error Model
Config-eval failures throw a ConfigError rather than calling process.exit.
This keeps the daemon safe:
- In-process loads (
zaps config) render a styled✖ <message>line and exit
non-zero. - Daemon-path commands (
zaps up,start, …) report the same message unstyled
and exit non-zero. - During a daemon reload, a thrown
ConfigErroris caught: the running session is
left fully intact (validate-then-swap), the old config stays live, and the error is
reported — it never tears down your services.
cli.fatal(message, opts?) is the explicit escape hatch — it throws a ConfigError
with your message (and optional field). Because it returns never, it composes in any
value position: name: pkg.name ?? cli.fatal("name required").
Config Notices
cli.warn, cli.info, and cli.success surface a short message without aborting:
- In a one-shot CLI run they print a styled line to stderr.
- During a daemon reload (with the TUI attached) they appear as transient toasts.
export function config({ define, cli }) {
if (!process.env.STRIPE_KEY) cli.warn("STRIPE_KEY unset — payments disabled");
return define({ services: { app: { start: "npm run dev" } } });
}
Async Config
The config function may be async:
export async function config({ define, node }) {
const pkg = JSON.parse(await node.fs.promises.readFile("package.json", "utf8"));
return define({ name: pkg.name, services: { app: { start: "npm run dev" } } });
}
Async evaluation is bounded by a 30-second timeout: if an async config hangs (awaiting
something that never resolves), the load fails with a ConfigError instead of blocking
a reload forever. The timeout only covers async waits — a synchronous infinite loop
isn't interruptible.
Services
Options
| Option | Type | Default | Description |
|---|---|---|---|
start |
string | () => string |
— | Command to start the service (long-running process) |
run |
string | () => string |
— | Interchangeable with start — either satisfies the "needs a command" rule (start wins if both are set) |
stop |
string | () => string |
— | Custom stop command (default: Ctrl-C) |
docker |
DockerConfig |
— | Docker Compose service config |
ready |
ReadyConfig |
— | How to detect the service is ready |
dependsOn |
string[] |
[] |
Services that must be ready first |
env |
Record<string, string> | (ctx) => Record |
— | Environment variables |
cwd |
string |
— | Working directory |
url |
string | (ctx) => string |
— | URL for browser open (o key) |
flags |
{ start?: boolean, open?: boolean } |
— | start: auto-start on launch (default true), open: auto-open URL when ready |
detached |
boolean |
false |
Run outside tmux (no pane) |
lazyPane |
boolean |
auto | Create the pane on start, drop it on explicit stop (default true when flags.start: false) |
raw |
boolean |
false |
Bypass wrapper — show env vars inline in pane |
restart |
{ maxRetries?, backoff? } |
— | Auto-restart on crash |
onBeforeStart |
() => void | Promise<void> |
— | Callback before command is sent |
onReady |
() => void | Promise<void> |
— | Callback when service becomes ready |
onStop |
() => void | Promise<void> |
— | Callback when service stops |
onOutput |
(line: string) => void | Promise<void> |
— | Called for each new output line |
optional |
boolean | () => Promise<boolean> |
— | Mark service as optional (see below) |
Optional Services
Mark services as optional when the binary may not be installed on all machines:
services: {
rainfrog: {
optional: true,
start: "rainfrog -u postgres://localhost:5432",
ready: { port: 5432 },
},
}
When optional: true, ZAPS checks if the binary exists via command -v. The binary is the first non-assignment token of start/run, so leading FOO=bar env-prefixes are skipped (FOO=bar rainfrog … probes rainfrog, not FOO=bar). A command with no real binary token (only assignments, or blank) is treated as unavailable. For function commands or custom checks, use the context helper:
services: {
rainfrog: {
optional: (ctx) => ctx.hasBinary("rainfrog"),
start: (ctx) => `rainfrog --url postgres://localhost:${ctx.services.db.ports[0]}`,
dependsOn: ["db"],
},
}
The optional predicate receives a context with helpers:
ctx.hasBinary(name)— checks if a binary exists viacommand -v
Combine checks naturally:
optional: async (ctx) => await ctx.hasBinary("grafana") && await ctx.hasBinary("prometheus"),
Behavior when unavailable:
- No tmux pane allocated
- Shown greyed out in TUI dashboard
dependsOn/restartWithreferences silently dropped- Layout automatically adjusts (empty splits collapsed)
Note:
optional: truerequiresstartorrunas a string (not a function). Use the function form withctx.hasBinary()for function commands or docker-only services.
Detached Services
Set detached: true to run a service pane-less — outside the tmux layout, with no
visible pane:
services: {
worker: {
start: "node worker.js",
detached: true,
ready: { port: 4000 },
},
}
- Full lifecycle still applies — start/stop/restart, ready detection, and crash
recovery all work the same as a paned service. zaps up -dstarts the whole session detached (services run, no TUI attached);zaps attachlater to view it.- No tmux pane, so there is no live terminal to scroll — read its output via the
log buffer (zaps logs worker,-fto stream). - A detached service must not appear in
layout, and cannot be combined withdockerorraw(both need a pane) — these are config load errors.
Lazy Panes
A lazy service is one whose tmux pane only exists while the process is
running: the pane is created at its exact declared layout position when the
service is started, and destroyed when the service is explicitly stopped.
A crash keeps the pane (so you can inspect the post-mortem output); a restart
keeps the pane (the process is replaced in place). The first manual start
brings the pane in; the first manual stop takes it out.
services: {
mailpit: {
start: "mailpit",
flags: { start: false }, // No autostart — opt-in via `s` / TUI / CLI.
ready: { port: 8025 },
},
}
Default rule: lazyPane: true when the service is non-autostart
(flags.start: false); lazyPane: false for autostart services. An explicitlazyPane: true | false always wins.
Docker groups and detached services are never lazy — even whenflags.start: false is set, a docker-group member keeps its shared group pane
and a detached: true service stays pane-less. The boot-skip rule applies to
own-pane services only, so the group/detached behavior is unchanged.
lazyPane is the lifecycle counterpart to detached:
detached services are NEVER paned (no terminal at all), while lazy
services are paned on demand (a real pane appears at start and disappears
on explicit stop).
Illegal combinations (config load errors):
lazyPane: truewithdetached: true— a detached service has no pane.lazyPane: trueon a docker-group member (a service expanded fromdocker.service: [...]withexpand) — group members share one pane, so
per-member lazy is ambiguous; applylazyPaneat the group level once
group-granularity lazy ships.
Ready Detection
Five strategies for detecting when a service is ready:
Port — wait for a TCP port:
ready: {
port: 3000;
} // specific port
ready: {
port: true;
} // any port
ready: {
port: () => getPort();
} // dynamic port
Output — match against pane output:
ready: {
output: /listening on port \d+/;
}
ready: {
output: (line) => line.includes("ready");
}
HTTP — poll an HTTP endpoint:
ready: { http: "/health" } // path — auto-detects port, then probes
ready: { http: "http://localhost:3000/health" } // full URL — probes directly
ready: { http: { url: "/api/health", status: 200 } } // require specific status code
When the URL starts with /, ZAPS re-detects the service's ports every poll and probes the path on http://127.0.0.1:{port}{path}, skipping debugger/HMR ports (9229-9240, 24678). A full URL is probed directly. If status is omitted, any HTTP response counts as ready.
Docker — wait for container running + healthy:
ready: { docker: "postgres" }
ready: { docker: ["postgres", "redis"] } // all must be ready
ready: { docker: "postgres", file: "./docker-compose.yml" }
Docker +
ready.port/ path-httpis a config load error. Published docker
ports are held bydockerd, not the service's pane, so port/PID detection can
never match — it would always time out. Use the default docker readiness, or a
full-URLready: { http: "http://127.0.0.1:<port>/path" }.
Function — custom async check:
ready: async () => {
const res = await fetch("http://localhost:3000/health");
return res.ok;
};
Ready checks poll every 500ms with a 60s timeout.
Docker Integration
When a service has docker config and no start/run, ZAPS auto-generates a docker compose up command.
If no ready config is provided, ZAPS defaults to checking the docker container state (running + healthy).
| Option | Type | Default | Description |
|---|---|---|---|
service |
string | string[] |
— | Docker Compose service name(s) (required) |
file |
string |
— | Path to compose file |
projectName |
string |
— | Pin the compose project name (see below) |
build |
boolean |
— | --build flag |
forceRecreate |
boolean |
— | --force-recreate flag |
renewVolumes |
boolean |
— | -V flag (recreate volumes) |
removeOrphans |
boolean |
— | --remove-orphans flag |
pull |
"always" | "missing" | "never" |
— | --pull strategy |
noDeps |
boolean |
— | --no-deps flag |
expand |
boolean | Record<string, object> |
— | Expand into individual services (see below) |
Compose version: Docker Compose v2.21+ is the tested baseline.
Compose project pinning
Every compose invocation is pinned to a deterministic project name so two
checkouts in same-named directories (e.g. …/foo/backend and …/bar/backend)
can't be mistaken for each other. The project name is resolved by precedence:
docker.projectName(this config field)ZAPS_COMPOSE_PROJECTenv var (read in the daemon process — set it where the daemon spawns)- the compose file's top-level
name: zaps-<sanitized-dir-name>-<hash>(default)
One-time recreate: switching to a pinned project recreates the service's
containers once. If containers already exist under the old (unpinned) name,
ZAPS prints a one-time warning suggestingdocker compose -p <old> down.
Tasks don't inherit the pin. Pinning is applied only to the compose commands
ZAPS runs fordockerservices. A task that shells out to baredocker compose …
runs under Compose's own default project name, so it won't see the containers ZAPS
started. Pass the project explicitly —docker compose -p "$ZAPS_COMPOSE_PROJECT" …
(setZAPS_COMPOSE_PROJECTwhere the daemon spawns) — or operate logically (e.g.prisma migrate resetinstead ofdocker compose down -v).
Expanded Docker Services
When you have multiple Docker Compose services that can share a single tmux pane, use expand: true to split them into individually addressable services:
services: {
infra: {
docker: {
service: ["postgres", "redis", "mailpit"],
expand: true,
},
restart: { maxRetries: 3 },
},
api: {
start: "npm run dev",
dependsOn: ["postgres"], // reference individual expanded service
},
}
This creates three individual services (postgres, redis, mailpit) that:
- Share a single tmux pane (one
docker compose upcommand) - Each have independent status, ready detection, and lifecycle
- Can be started/stopped/restarted individually
- Can be referenced individually in
dependsOn - Appear as grouped rows in the TUI dashboard
Layout references use the group name: { pane: "infra" }.
Use expand: { ... } instead of expand: true to provide per-child overrides:
services: {
infra: {
docker: {
service: ["caddy", "postgres", "mailpit", "bugsink"],
expand: {
postgres: {
onReady: () => task.run("prisma:deploy"),
},
bugsink: {
ready: { http: "http://localhost:8000/health/ready" },
},
},
},
},
}
Children without overrides inherit the parent config. Overrides can set ready, env, onReady, onStop, onBeforeStart, url, flags, restart, etc.
An override cannot set start, run, or docker (the command and docker config are inherited from the parent group — overriding them would silently break the shared pane), and unknown keys (e.g. a typo like redy:) are rejected. Any forbidden or unknown key is a config load error naming the group, child, and offending key. expand also works when service is a single string (it expands to one child), not only on arrays.
Dependencies
Services start in topological order. Services at the same level start in parallel:
services: {
db: { start: "..." }, // Level 0 — starts first
cache: { start: "..." }, // Level 0 — parallel with db
api: {
start: "...",
dependsOn: ["db", "cache"], // Level 1 — waits for both
},
web: {
start: "...",
dependsOn: ["api"], // Level 2 — waits for api
},
}
Shutdown runs in reverse topological order.
Crash Recovery
Enable auto-restart with exponential backoff:
restart: {
maxRetries: 5, // default: 3
backoff: 2000, // base delay in ms, default: 1000
}
Backoff doubles per retry: 2s → 4s → 8s → 16s → 32s. Crash monitoring polls the
pane every 2s for raw-mode services and every 10s for wrapper-mode ones (where the
wrapper's exit notification is the primary signal, so the poll is just a backstop).
Dynamic Environment
Access other services' runtime info via ServiceContext:
env: (ctx) => ({
DATABASE_URL: `postgres://localhost:${ctx.services.db.port}/mydb`,
API_PORTS: ctx.services.api.ports.join(","),
PROJECT_DIR: ctx.projectDir,
});
ServiceContext shape:
{
services: Record<
string,
{
port: number | undefined; // first detected port
ports: number[]; // all detected ports
cwd: string | undefined; // service's configured cwd, else projectDir
}
>;
projectDir: string;
url(service: string, opts?: UrlOptions): string | null;
}
ctx.url()
ctx.url(service, opts?) builds a URL from a service's detected port, so you don't
have to hand-assemble strings:
env: (ctx) => ({
API_URL: ctx.url("api"), // "http://localhost:3000"
DATABASE_URL: ctx.url("db", { protocol: "postgres", auth: "user:pass", path: "/mydb" }),
// → "postgres://user:pass@localhost:5432/mydb"
});
UrlOptions: protocol (default "http"), auth (e.g. "user:pass"), host
(default "localhost"), port (override the detected port), path (a leading / is
added if missing). IPv6 hosts are bracketed automatically (http://[::1]:3000).
Behavior:
- Unknown service → throws a
ConfigError. - No port detected yet (and no
portoverride) → returnsnull. - A
nullresult is dropped from an env object — the variable is simply omitted
rather than set to an empty string, soAPI_URL: ctx.url("api")is safe even before
the port is up.
ctx.url() is available wherever a ServiceContext is — dynamic env, command
functions, the url: field, and task run callbacks (also as ctx.services.url()).
Tasks
One-off commands runnable from the TUI:
tasks: {
migrate: {
name: "Run migrations",
description: "Apply pending database migrations",
commands: "npx prisma migrate deploy",
cwd: "./packages/api",
dependsOn: ["seed"],
env: { NODE_ENV: "production" },
shortcut: "m",
},
"reset-db": {
name: "Reset database",
commands: [
"npx prisma migrate reset --force",
"npx prisma db seed",
],
},
"interactive-migrate": {
name: "Interactive migration",
commands: "npx prisma migrate dev",
popup: { width: "80%", height: "80%" },
},
}
Task Options
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Display name in the TUI |
description |
string |
— | Description shown in the task picker |
commands |
string | string[] |
— | Shell command(s) to run |
run |
(ctx: TaskRunContext) => void | Promise<void> |
— | Programmatic task function (mutually exclusive with commands) |
cwd |
string |
— | Working directory |
env |
Record<string, string> |
— | Environment variables |
dependsOn |
string[] |
[] |
Tasks that must run first |
shortcut |
string |
— | Hint key shown beside the task in the picker |
popup |
boolean | { width?: string; height?: string } |
— | Run in tmux popup window (commands only) |
Task dependencies are resolved and executed before the task itself.
Programmatic Tasks
Use run instead of commands for full programmatic control:
tasks: {
"check-health": {
name: "Health check",
run: async ({ exec, stdout, services, projectDir }) => {
const { success, output } = await exec("curl -sf http://localhost:3000/health");
stdout.write(success ? "API healthy" : "API down");
const result = await exec("npm test", { cwd: "./packages/api" });
if (!result.success) throw new Error("Tests failed");
},
},
}
TaskRunContext shape:
{
exec(cmd: string, opts?: { cwd?: string; env?: Record<string, string> }): Promise<ExecResult>;
stdout: { write(text: string): void };
services: ServiceContext; // same as dynamic env context
projectDir: string;
url(service: string, opts?: UrlOptions): string | null; // mirrors services.url()
}
ExecResult shape:
{
success: boolean;
exitCode: number;
output: string[];
}
commandsandrunare mutually exclusive — a task must use one or the other.
Shortcuts
Tasks can define a shortcut key, shown as a hint beside the task in the picker.
If no shortcut is specified, ZAPS auto-assigns the first unique character from the
task key.
Press t on the dashboard to open the fuzzy task picker, then type to filter andEnter/Tab to run (see Task picker).
Reserved keys
The keys q, j, and k are reserved and are never auto-assigned to a task (q detaches, j/k are list navigation). If a task explicitly requests one of them via shortcut, the shortcut is dropped (no fallback is assigned) and ZAPS prints a load-time warning:
Warning: task 'deploy' ('Deploy') requests reserved shortcut 'q'; 'q' is reserved (q=quit, j/k=navigation) and the shortcut is dropped.
Pick a different key for the task to keep a shortcut.
Layout
Define a custom tmux pane layout. The @tui pane is required — it hosts the ZAPS dashboard.
layout: {
direction: "columns",
children: [
{ pane: "@tui", size: "25" },
{
direction: "rows",
children: [
{ pane: "api", size: "60" },
{ pane: "web", size: "40" },
],
},
],
}
direction:"rows"(vertical split) or"columns"(horizontal split)size: percentage of parent (defaults to equal split)focus: set totrueon one leaf pane to auto-focus it after layout creation (at most one)- Services not in the layout get their own vertical split pane in the
@tuiwindow - Detached services must not appear in the layout
If no layout is specified, @tui gets the main pane and each non-detached service gets a vertical split pane in the same window.
TUI
The TUI is built around a dashboard, overlays (command palette, task picker, help,
failed-output), and a responsive layout that adapts to the pane size.
Global keys
Work from any base view; survive a disconnect; yield to an open overlay.
| Key | Action |
|---|---|
Ctrl-K : |
Open the command palette (fuzzy actions) |
? |
Toggle the help overlay |
t |
Open the task picker |
f |
Open the captured output of the latest failure |
x |
Acknowledge — clear sticky failure toasts |
q Ctrl-C |
Detach (services keep running) |
Ctrl-D |
Shut down the session (stop services + destroy) |
Esc |
Close the top overlay / leave the current view |
Dashboard
| Key | Action |
|---|---|
Up/Down/j/k |
Navigate services |
r |
Restart selected service |
s |
Start/stop selected service |
l |
View logs for selected service |
o |
Open service URL in browser |
R |
Docker rebuild (docker services) |
z / Z |
Zoom the service pane / the TUI pane |
E |
Edit-capture the selected pane |
a |
Restart all services |
c |
Reload config (when changed + idle) |
t |
Open the task picker |
d |
Shut down the session |
On wide panes a detail pane shows the selected service's fields; it collapses
when cols < ui.wideThreshold (default 100). See UI Config.
Command palette (Ctrl-K / :)
| Key | Action |
|---|---|
| type | Fuzzy-filter actions |
Up/Down |
Move selection |
Enter |
Run / confirm |
Esc |
Close |
Task picker (t)
Fuzzy picker over the project's tasks. Each task can run in two modes:
| Key | Action |
|---|---|
| type | Fuzzy-filter tasks |
Up/Down |
Move selection |
Enter |
Run in the default mode (ui.task.defaultMode, default background) |
Tab |
Run live in a tmux pane |
Esc |
Close |
- Background runs stream into the daemon's retained output buffer; success
shows a transient toast, failure a sticky one. - Run-in-pane opens a tmux pane/window running the task live; the pane stays
open on completion so the output stays inspectable.
Set ui.task.popupPicker: true to use an fzf tmux popup instead of the in-app
picker (falls back to the in-app picker when tmux < 3.2 or fzf is absent).
Log view (l)
| Key | Action |
|---|---|
Up/Down/j/k |
Scroll logs |
Esc |
Back to dashboard |
Scroll up to pause auto-follow; scroll back to the bottom to resume live tailing.
Notifications & failed output
A finished background task raises an in-app toast: success is transient,
failure is sticky and stays until acknowledged with x. On failure ZAPS also
emits an out-of-band terminal notification per ui.notifications (defaultosc9).
Press f to open the failed-output overlay for the latest sticky failure
(Up/Down/j/k scroll, Esc close). Inside it, p escalates to a larger tmuxdisplay-popup (when tmux supports it). With ui.failOutput: popup the overlay
escalates straight to the popup on open.
UI Config
The optional ui block tunes TUI presentation. Every field has a safe default,
so omitting ui (or any field) is fine.
ui: {
icons: "nerd", // "nerd" | "unicode" | "ascii"
notifications: "osc9", // "off" | "bell" | "osc9" | "osc9+bell"
failOutput: "overlay", // "overlay" | "popup"
wideThreshold: 100, // min cols to show the detail pane (integer ≥ 40)
task: {
defaultMode: "background", // "background" | "pane" — Enter in the task picker
popupPicker: false, // open the task picker as an fzf tmux popup
},
}
| Option | Type | Default | Description |
|---|---|---|---|
icons |
"nerd" | "unicode" | "ascii" |
"nerd" |
Glyph tier. ZAPS_ICONS env overrides the config value. |
notifications |
"off" | "bell" | "osc9" | "osc9+bell" |
"osc9" |
Out-of-band failure notification channel (OSC 9 desktop notification and/or terminal bell). |
failOutput |
"overlay" | "popup" |
"overlay" |
Where the f failed-output view opens; popup escalates straight to a tmux popup. |
wideThreshold |
number (int ≥ 40) |
100 |
Minimum terminal columns to show the detail pane; below it the pane collapses. |
task.defaultMode |
"background" | "pane" |
"background" |
Mode for Enter in the task picker (Tab always runs in a pane). |
task.popupPicker |
boolean |
false |
Use an fzf tmux popup picker instead of the in-app one (falls back when unavailable). |
Service States
Valid transitions (the manager rejects any other move):
stopped → starting
starting → ready | error | stopping
ready → stopping | restarting | error
stopping → stopped
restarting → starting | stopping | error
error → starting (retry / manual start)
unavailable → — (terminal: optional service whose binary/check failed)
| State | Indicator |
|---|---|
ready |
Green ● |
starting / stopping / restarting |
Yellow spinner |
error |
Red ✖ |
stopped |
Gray ○ |
unavailable |
Gray ○ |
Hooks
Global Hooks
Lifecycle hooks for custom logic at the project level:
hooks: {
onBeforeStart: async () => { /* runs once before any service starts */ },
onStart: async () => { /* all services started */ },
onStop: async () => { /* cleanup before exit */ },
}
Per-Service Hooks
Services also support their own hooks for service-specific logic:
services: {
api: {
start: "npm run dev",
onBeforeStart: () => console.log("Setting up API"),
onReady: () => console.log("API is up"),
onStop: () => console.log("API stopped"),
onOutput: (line) => {
if (/error/i.test(line)) sendAlert(line);
},
},
}
AI Integration
ZAPS offers two integration paths for AI coding agents: Claude Code Skills (recommended) and MCP. Skills are more token-efficient since they load context on-demand, while MCP provides a protocol-level interface usable by any MCP-compatible client.
Agent Priming
Use zaps prime-agent to get a concise TOON overview of all services (with runtime state and ports) and tasks. Useful for bootstrapping an AI agent's context about the current project.
Claude Code Skills
ZAPS ships two Claude Code skills in .claude/skills/:
| Skill | Description |
|---|---|
zaps-usage |
Interact with dev sessions — start/stop services, run tasks, view logs |
zaps-config |
Author and edit ZAPS config files (.zaps.mts, .zaps.ts, local.zaps.ts) |
Skills are recommended over MCP because they load reference docs on-demand rather than occupying persistent context, resulting in significantly lower token usage.
Installation
Copy the .claude/skills/ directory into your project:
cp -r node_modules/@bosdev/zaps/.claude/skills/ .claude/skills/
Claude Code will automatically discover and use the skills when relevant.
MCP Server
ZAPS exposes an MCP server that lets AI agents manage services, run tasks, and read logs.
zaps mcp # auto-detects session from CWD
zaps mcp --session my-app # target specific session
Available Tools
| Tool | Description |
|---|---|
services_list |
List all services and their statuses |
services_details |
Get details for a specific service |
services_start |
Start a service |
services_stop |
Stop a service |
services_restart |
Restart a service |
services_start_all |
Start all (or specific) services |
services_stop_all |
Stop all (or specific) services |
services_restart_all |
Restart all (or specific) services |
logs_snapshot |
Get recent log lines for a service |
tasks_list |
List available tasks |
tasks_run |
Run a task and return its output |
Resources
| URI | Description |
|---|---|
zaps://logs/{serviceName} |
Live log output (subscribable) |
Claude Code Setup
claude mcp add zaps -- zaps mcp
Or manually add to .mcp.json in your project root:
{
"mcpServers": {
"zaps": {
"command": "zaps",
"args": ["mcp"]
}
}
}
CLAUDE.md Setup
Add the following to your project's CLAUDE.md to help Claude use ZAPS effectively:
## ZAPS
- Use the `zaps-usage` skill to manage dev sessions (start/stop services, run tasks, view logs)
- Use the `zaps-config` skill when editing ZAPS config files
Troubleshooting
zaps daemon stop tears down services
zaps daemon stop now performs a full cleanup: it stops every service in every
session the daemon manages before shutting the daemon down, and reports what it tore
down (Stopped <n> session(s), <m> service(s).). It is no longer a bare process kill.
Error messages you may see
Port already in use — before starting a service, ZAPS pre-flights its expected
host ports. If one is taken it fails fast with an attributed message instead of
letting the service crash on bind:Port 5432 already in use (pid 1234 postgres)Dependency not ready — when a service can't start because one of its
dependsOnservices never became ready, the dependent'slastError(shown in the
dashboard) reads:Dependency "db" not ready
License
MIT
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found