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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .agents/skills/using-eph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,86 @@ DATABASE_URL=postgres://dev@localhost:${postgres.port}/app
hooks run on the host via `sh -c` with eph's resolved environment injected (see
below).

## Roles: dependency services vs the app

A `.eph` file can split its services into tiers with a `role=` on each service
and a top-level `roles_order`. The usual split is dependency services (Postgres,
Redis, object storage: things the code talks to, safe to start eagerly) from the
first-party app you are building (start it on demand; it may bind preview ports
or run side effects). Roles let you bring up one tier without the other.

```ini
roles_order=dep,app # dep services come up before the app

[postgres]
image=postgres:16
role=dep

[web]
run=npm run dev
port=auto
role=app
```

- `roles_order=dep,app` is the linear form: each role depends on the one before
it. For a graph (a `worker` that needs `dep` but not `app`), use a section
instead, where each line is `role=dependencies` and a bare `role=` is a root:

```ini
[roles_order]
dep=
app=dep
worker=dep
```

- Roles are all-or-nothing: once any service has a `role=`, a `roles_order` is
required, every service must declare a role listed in it, every listed role
must have a service, and the graph must be acyclic. `eph check` reports any
violation. A file with no roles at all keeps the old behavior (declaration
order, `run=` services last), so nothing needs roles.
- Bring-up follows the role graph (dependencies first); teardown reverses it.
- `eph up --role <ROLE>` starts that role **and everything it depends on**, and
nothing else. Repeatable, and it unions with any positional service names.
`eph up --role dep` starts just the dependency tier. `eph down --role <ROLE>`
tears down that role and everything that depends on it.

## Prewarm dependency services at session start

Because `eph up --role dep` starts the dependency tier without the app, it is the
natural thing to run from a Claude Code **SessionStart hook**: the databases and
caches come up known-good and their connection env is ready before your first
command, so you never hit "the service isn't running" and restart your work. A
later `eph up` or `eph dev` reuses those already-running services, and `eph dev`
leaves them up when it exits (it tears down only the app it started).

```sh
#!/usr/bin/env bash
# .claude/hooks/eph-prewarm.sh: prewarm deps and inject their connection env.
# $CLAUDE_ENV_FILE is sourced by Claude Code, so later Bash tool calls in the
# session inherit DATABASE_URL, REDIS_URL, and the rest.
test -f .eph || exit 0
eph up --role dep || exit 0
[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE"
```

```json
// .claude/settings.json: run it on session start (project scope: everyone in
// the repo/worktree gets it). Use ~/.claude/settings.json for a personal one.
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume",
"hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] }
]
}
}
```

Substitute your own dependency role name for `dep`. To also seed on prewarm, drop
the default (post-start hooks run); to prewarm bare, add `--skip-hooks`. If you
want the tier torn down when a session ends, add a `SessionEnd` hook running
`eph down --role dep`; the default is to leave it warm for the next session.

## Lifecycle hooks see eph's environment

Four hooks bracket a service, in order: `pre-start` (before it is created),
Expand Down Expand Up @@ -209,6 +289,10 @@ default, or `eph clean` with `--clean` (each running `pre-stop` then
- Teardown defaults to `eph down` (keeps data for a fast relaunch, since Claude
restarts the server during a session). Use `eph dev --clean` (`runtimeArgs:
["dev", "--clean"]`) for a pristine reset on every launch.
- `eph dev` tears down only the services it actually started. Any that were
already running when it launched (a dependency tier a SessionStart hook
prewarmed) are left up, so the deps stay hot across `eph dev` runs. `--clean`
overrides this and bulldozes everything.
- A hard kill (not a normal stop) skips teardown and leaves services up,
recoverable with `eph down`. If the app crashes on its own, `eph dev` leaves
services up for inspection (`eph logs <service>`) and exits non-zero.
Expand Down
84 changes: 84 additions & 0 deletions .claude/skills/using-eph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,86 @@ DATABASE_URL=postgres://dev@localhost:${postgres.port}/app
hooks run on the host via `sh -c` with eph's resolved environment injected (see
below).

## Roles: dependency services vs the app

A `.eph` file can split its services into tiers with a `role=` on each service
and a top-level `roles_order`. The usual split is dependency services (Postgres,
Redis, object storage: things the code talks to, safe to start eagerly) from the
first-party app you are building (start it on demand; it may bind preview ports
or run side effects). Roles let you bring up one tier without the other.

```ini
roles_order=dep,app # dep services come up before the app

[postgres]
image=postgres:16
role=dep

[web]
run=npm run dev
port=auto
role=app
```

- `roles_order=dep,app` is the linear form: each role depends on the one before
it. For a graph (a `worker` that needs `dep` but not `app`), use a section
instead, where each line is `role=dependencies` and a bare `role=` is a root:

```ini
[roles_order]
dep=
app=dep
worker=dep
```

- Roles are all-or-nothing: once any service has a `role=`, a `roles_order` is
required, every service must declare a role listed in it, every listed role
must have a service, and the graph must be acyclic. `eph check` reports any
violation. A file with no roles at all keeps the old behavior (declaration
order, `run=` services last), so nothing needs roles.
- Bring-up follows the role graph (dependencies first); teardown reverses it.
- `eph up --role <ROLE>` starts that role **and everything it depends on**, and
nothing else. Repeatable, and it unions with any positional service names.
`eph up --role dep` starts just the dependency tier. `eph down --role <ROLE>`
tears down that role and everything that depends on it.

## Prewarm dependency services at session start

Because `eph up --role dep` starts the dependency tier without the app, it is the
natural thing to run from a Claude Code **SessionStart hook**: the databases and
caches come up known-good and their connection env is ready before your first
command, so you never hit "the service isn't running" and restart your work. A
later `eph up` or `eph dev` reuses those already-running services, and `eph dev`
leaves them up when it exits (it tears down only the app it started).

```sh
#!/usr/bin/env bash
# .claude/hooks/eph-prewarm.sh: prewarm deps and inject their connection env.
# $CLAUDE_ENV_FILE is sourced by Claude Code, so later Bash tool calls in the
# session inherit DATABASE_URL, REDIS_URL, and the rest.
test -f .eph || exit 0
eph up --role dep || exit 0
[ -n "$CLAUDE_ENV_FILE" ] && eph env >> "$CLAUDE_ENV_FILE"
```

```json
// .claude/settings.json: run it on session start (project scope: everyone in
// the repo/worktree gets it). Use ~/.claude/settings.json for a personal one.
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume",
"hooks": [ { "type": "command", "command": ".claude/hooks/eph-prewarm.sh" } ] }
]
}
}
```

Substitute your own dependency role name for `dep`. To also seed on prewarm, drop
the default (post-start hooks run); to prewarm bare, add `--skip-hooks`. If you
want the tier torn down when a session ends, add a `SessionEnd` hook running
`eph down --role dep`; the default is to leave it warm for the next session.

## Lifecycle hooks see eph's environment

Four hooks bracket a service, in order: `pre-start` (before it is created),
Expand Down Expand Up @@ -209,6 +289,10 @@ default, or `eph clean` with `--clean` (each running `pre-stop` then
- Teardown defaults to `eph down` (keeps data for a fast relaunch, since Claude
restarts the server during a session). Use `eph dev --clean` (`runtimeArgs:
["dev", "--clean"]`) for a pristine reset on every launch.
- `eph dev` tears down only the services it actually started. Any that were
already running when it launched (a dependency tier a SessionStart hook
prewarmed) are left up, so the deps stay hot across `eph dev` runs. `--clean`
overrides this and bulldozes everything.
- A hard kill (not a normal stop) skips teardown and leaves services up,
recoverable with `eph down`. If the app crashes on its own, `eph dev` leaves
services up for inspection (`eph logs <service>`) and exits non-zero.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ eval "$(eph env)" # load connection strings into your shell
eph down # stop when you are done
```

Tag services with a `role=` (and a `roles_order`) to split the stack into tiers and
bring up one at a time: `eph up --role dep` starts just the dependency services (for
example to prewarm databases in a Claude Code SessionStart hook) without starting
your app. See [Roles and ordering](docs/user-guide/eph-file.md#roles-and-ordering).

[Getting Started](docs/user-guide/getting-started.md) walks through writing the
`.eph` file from scratch.

Expand Down
51 changes: 39 additions & 12 deletions docs/user-guide/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,24 @@ service names, starts only those.

| Flag | Description |
|------|-------------|
| `--role ROLE` | Bring up this role and everything it depends on (its forward/dependency closure). Repeatable. Requires a `roles_order`; combines with any SERVICE names. |
| `--skip-hooks` | Bring services up healthy but do not run their `pre-start` or `post-start` hooks. |

```sh
eph up # all services
eph up postgres redis # just these two
eph up --role dep # the dep tier and its dependencies (e.g. prewarm the database)
eph up --role app # the app plus every role it depends on
eph up --skip-hooks # start everything but skip pre-start/post-start (e.g. codegen, migrations)
```

- `--role ROLE` (repeatable) selects a role and its **dependency closure**: the
role plus every role it transitively depends on, since a role cannot run without
the roles below it. `--role app` with `roles_order=dep,app` starts both `dep` and
`app`; `--role dep` starts only `dep`. It combines with positional SERVICE names,
starting the union. Using `--role` on a file that defines no `roles_order` is an
error saying so. See [Roles and ordering](eph-file.md#roles-and-ordering).

- Idempotent: a running service is reused; a stopped-but-present container is
restarted; otherwise a fresh container is created.
- Each service runs its `pre-start` hooks just before it is created (the place
Expand Down Expand Up @@ -60,16 +70,26 @@ already stopped.

| Flag | Description |
|------|-------------|
| `--role ROLE` | Stop this role and everything that depends on it (its reverse/dependent closure), in reverse start order. Repeatable. Requires a `roles_order`; combines with any SERVICE names. |
| `-r`, `--rm` | Also remove the stopped containers (not just stop them). |
| `--skip-hooks` | Stop without running `pre-stop` or `post-stop` hooks (escape hatch for a broken hook). |

```sh
eph down # stop all, keep containers
eph down --rm # stop all and remove containers
eph down postgres # stop just postgres
eph down --role dep # stop the dep tier and everything that depends on it
eph down --skip-hooks # stop without running pre-stop/post-stop hooks
```

- `--role ROLE` (repeatable) selects a role and its **dependent closure**: the role
plus every role that transitively depends on it, torn down in reverse start order,
because a dependency cannot be removed while the roles that need it are still up.
With `roles_order=dep,app`, `eph down --role dep` stops both `app` and `dep`. It
combines with positional SERVICE names, and requires a `roles_order` (an error
otherwise). `eph down` is otherwise absolute: it stops exactly what it targets,
with no ownership logic. See [Roles and ordering](eph-file.md#roles-and-ordering).

Without `--rm`, containers and their data remain for a fast restart. With
`--rm`, containers are removed (named-volume data is kept); the next `eph up`
creates fresh containers.
Expand Down Expand Up @@ -116,11 +136,13 @@ Run the whole dev stack in the foreground, built for a Claude Desktop preview
server (see [Recipes](recipes.md#claude-desktop-preview-servers)). It brings
every service up (running `post-start` hooks, e.g. seeding), foregrounds a
`run=` service with eph's own stdin, stdout, and stderr wired through to it, and
stays attached until it is stopped. On stop it tears the stack down.
stays attached until it is stopped. On stop it tears down only the services it
brought up itself, leaving any that were already running when it started (a
prewarmed dependency tier, typically) up.

| Flag | Description |
|------|-------------|
| `--clean` | Tear down with `eph clean` (drop named volumes and their data) instead of the default `eph down` (keep them for a fast restart). |
| `--clean` | On the final stop, tear the **whole** workspace down with `eph clean` (drop named volumes and their data), rather than the default of stopping only the services `eph dev` brought up and keeping the rest. |
| `--watch GLOB` | Restart the whole stack when a file matching GLOB changes. Repeatable; globs are relative to the workspace root with gitignore-style separators. |

```sh
Expand Down Expand Up @@ -151,10 +173,14 @@ eph dev --watch "**/*.rs" --watch "*.toml" # restart on source changes
instead of the moment the server can answer its health check. Give the
foreground app `port=auto` and read its `${service.port}`; do not also pin a
fixed port.
- **Teardown on stop**: a stop signal (the preview server's, or Ctrl-C) runs
`eph down` by default, or `eph clean` with `--clean`, then exits zero. A hard
kill (`SIGKILL` / `TerminateProcess`) cannot be caught, so it skips teardown
and leaves the stack up, recoverable with `eph down`.
- **Teardown on stop**: a stop signal (the preview server's, or Ctrl-C) stops only
the services `eph dev` brought up, then exits zero. `eph dev` snapshots what was
already running at startup (a simple in-memory record, no persisted refcount) and
leaves those services up, so a dependency tier a SessionStart hook prewarmed stays
warm for the next command. With `--clean` it instead runs `eph clean` and
bulldozes the whole workspace, volumes included. A hard kill (`SIGKILL` /
`TerminateProcess`) cannot be caught, so it skips teardown and leaves the stack
up, recoverable with `eph down`.
- **App exit**: if the foregrounded app exits on its own (a crash), `eph dev`
leaves the backing services up and exits non-zero, so the preview server sees
the dev server went down. The app's own output already streamed to your
Expand All @@ -163,12 +189,13 @@ eph dev --watch "**/*.rs" --watch "*.toml" # restart on source changes
paths relative to the workspace root, using gitignore-style separators: `*`
stays within a directory and `**` spans them, so `*.toml` matches a top-level
`Cargo.toml` while `**/*.rs` matches a `.rs` file at any depth. When a matching
file changes, eph tears the whole stack down (running `pre-stop` and
`post-stop` hooks) and brings it back up (running `pre-start` and `post-start`
hooks), so a restart is a full `eph down` + `eph dev`, not a bare process
bounce, and every lifecycle hook fires just as it would on a manual restart. A
restart always keeps volumes for speed, even under `--clean`; that reset is
reserved for the final stop. Changes are debounced, so one save is one restart,
file changes, eph tears down the services it brought up (running `pre-stop` and
`post-stop` hooks) and brings them back up (running `pre-start` and `post-start`
hooks), so a restart is a full down + up, not a bare process bounce, and every
lifecycle hook fires just as it would on a manual restart. Only the services
`eph dev` brought up are bounced: an adopted, already-running dependency tier
stays hot across restarts. A restart always keeps volumes for speed, even under
`--clean`; that reset is reserved for the final stop. Changes are debounced, so one save is one restart,
and git's own churn under `.git` never triggers one. Without any `--watch` the
stack never restarts.
In watch mode an app that exits on its own (a crash) does not end the session:
Expand Down
41 changes: 38 additions & 3 deletions docs/user-guide/concepts.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Core Concepts

This page explains the model behind `eph`. Once these five ideas click -
workspaces, isolation, automatic ports, persisted state, and the lifecycle -
the commands and the file format are obvious.
This page explains the model behind `eph`. Once these ideas click (workspaces,
isolation, automatic ports, persisted state, the lifecycle, and the split between
dependency services and the app), the commands and the file format are obvious.

## Workspaces

Expand Down Expand Up @@ -178,6 +178,41 @@ but the service is already stopped. Both are skippable with `--skip-hooks`.
> named volumes declared with `volume=` in your `.eph` file; volumes declared
> inside a Compose file are left to `docker compose`.

## Dependency services vs the app

Most stacks split in two: **dependency services** (databases, caches, queues, mail
catchers) that your app talks to, and the **first-party app** you are building.
The dependency tier is stable and can run ahead of time; the app is what you
restart constantly and want to control precisely.

`role=` names that split, and `roles_order` orders it. Tag the backing services
`role=dep` and the app `role=app`, then declare `roles_order=dep,app` ("app depends
on dep"). Now the two tiers are addressable:

- **Start order follows the graph.** `eph up` brings the `dep` tier up first and
waits for it healthy, then the app, so the app's `DATABASE_URL` resolves the
moment it starts. Teardown reverses that. This replaces the legacy "start `run=`
services last" rule with an explicit ordering you control.
- **You can bring up one tier.** `eph up --role dep` starts the dependency services
and everything they depend on, without touching the app. `eph up --role app`
starts the app and pulls its dependencies in with it (see
[Command Reference](command-reference.md#eph-up-service)).

The motivating case is prewarming. A dependency tier is known-good and slow to
build (image pulls, migrations, seeds), so warming it once and reusing it beats
starting it per task. A Claude Code SessionStart hook can run `eph up --role dep`
and inject the resolved connection env before an agent's first command, leaving the
app for later. Because `eph up` is idempotent, a later `eph up` or `eph dev` reuses
the already-running dependency services rather than restarting them, and `eph dev`
on exit leaves the tier it adopted running. See
[For Agents](for-agents.md#prewarm-dependency-services-on-session-start) for the
recipe.

A file that uses no `role=` and no `roles_order` stays in **legacy mode** and
behaves exactly as before: declaration order, `run=` services last. Roles are
opt-in. Full rules are in
[The `.eph` File](eph-file.md#roles-and-ordering).

## Next

Now that you have the model, see [The `.eph` File](eph-file.md) for the complete
Expand Down
Loading
Loading