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 it
export 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 a
ConfigError 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
===/instanceofidentical 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 a
config 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 explicit
lazyPane: true | false always wins.
Docker groups and detached services are never lazy — even when
flags.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 suggesting
docker compose -p <old> down.
Tasks don't inherit the pin. Pinning is applied only to the compose commands ZAPS runs for
dockerservices. 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 and
Enter/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 t
No comments yet
Be the first to share your take.