zaps

mcp
Guvenlik Denetimi
Basarisiz
Health Uyari
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 5 GitHub stars
Code Basarisiz
  • 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 Gecti
  • Permissions — No dangerous permissions requested

Bu listing icin henuz AI raporu yok.

SUMMARY

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.

README.md

ZAPS

npm version Node engine CI status MIT License Tmux >= 3.5a


⚡ Painless project setup ⚡

Zero Ass Pain Setup — a terminal session manager that orchestrates dev services inside tmux with an interactive TUI.
Define services, dependencies, tasks, and layout in a single config file. ZAPS handles tmux pane layout, service lifecycle, crash recovery, and a keyboard-driven dashboard.

Prerequisites

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 set ZAPS_FORMAT=json|toon to make it the default. Coding agents (detected via CLAUDECODE / CURSOR_TRACE_DIR) default to TOON automatically.

Configuration

Config Discovery

ZAPS walks up from the current directory looking for these filenames (first match wins):

  1. .local.zaps.mts — local override (gitignore this)
  2. local.zaps.mts
  3. .local.zaps.ts
  4. local.zaps.ts
  5. .zaps.mts — primary config
  6. .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 if filename is 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 ===/instanceof
    identical to the next. Don't rely on cross-reload object identity.
  • node_modules reached 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 ConfigError is 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 via command -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/restartWith references silently dropped
  • Layout automatically adjusts (empty splits collapsed)

Note: optional: true requires start or run as a string (not a function). Use the function form with ctx.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 -d starts the whole session detached (services run, no TUI attached);
    zaps attach later to view it.
  • No tmux pane, so there is no live terminal to scroll — read its output via the
    log buffer (zaps logs worker, -f to stream).
  • A detached service must not appear in layout, and cannot be combined with
    docker or raw (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: true with detached: true — a detached service has no pane.
  • lazyPane: true on a docker-group member (a service expanded from
    docker.service: [...] with expand) — group members share one pane, so
    per-member lazy is ambiguous; apply lazyPane at 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-http is a config load error. Published docker
ports are held by dockerd, 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-URL ready: { 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:

  1. docker.projectName (this config field)
  2. ZAPS_COMPOSE_PROJECT env var (read in the daemon process — set it where the daemon spawns)
  3. the compose file's top-level name:
  4. 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 docker services. A task that shells out to bare docker 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" …
(set ZAPS_COMPOSE_PROJECT where the daemon spawns) — or operate logically (e.g.
prisma migrate reset instead of docker 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 up command)
  • 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 port override) → returns null.
  • A null result is dropped from an env object — the variable is simply omitted
    rather than set to an empty string, so API_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[];
}

commands and run are 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 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 to true on 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 @tui window
  • 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 (default
osc9).

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 tmux
display-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
    dependsOn services never became ready, the dependent's lastError (shown in the
    dashboard) reads:

    Dependency "db" not ready
    

License

MIT

Yorumlar (0)

Sonuc bulunamadi