lazyshell

Project configuration

Run lazyshell in a directory holding a lazyshell.yml and it starts the sessions that file declares — each in its own directory, with its own environment and command — instead of coming up empty.

The idea

lazyshell init writes a commented starting point in the current directory. Sessions start in file order, and the first one is selected. An entry that does not validate (empty or duplicate name, missing cwd) is skipped and reported in the status bar — the others still start.

Example

# ./lazyshell.yml

# Optional: overrides the user config's shell, for this project only.
shell: /bin/zsh

# Optional: .env files loaded for every session below, in order — a later
# file overrides a key set by an earlier one.
env_files:
  - .env
  - .env.local

# Optional: declares the groups this project uses, and — the reason to write
# this block at all — the order their headers appear in the sessions panel.
groups:
  - name: services
  - name: agents

sessions:
  - name: api
    # Optional: the group this session starts in. Change it later with `g`.
    group: services
    # Relative to *this file*, not to where you launched lazyshell from.
    # `~` is expanded. Left out, it means this file's own directory.
    cwd: ./services/api
    # Typed into the shell once it is up, not exec'd in its place: when the
    # command exits (or you Ctrl-C it), the shell is still there.
    command: make dev
    env:
      PORT: "3000"
    # Optional: on top of env_files above, for this session only.
    env_files:
      - .env.api
    # Optional: notify when a line matches. Toggle one on the fly with `v`.
    watch:
      - pattern: "ERR!"
        notify: true
    # Optional: never (default) | on-failure | always. Restarts the shell
    # automatically when the command exits, with a delay that doubles each
    # consecutive attempt (1s, 2s, 4s... capped at 60s) and resets once a
    # restarted run stays up 10s. "R" (or "W" for the group) restarts right
    # away, bypassing the wait.
    restart: on-failure
    # Optional, false by default. When the command exits non-zero, kill the
    # session outright instead of leaving the shell open underneath — the
    # opposite of the default just above. Has no effect without a command:
    # there is nothing to watch.
    stop_on_failure: false
    # Optional. A session declaring a `command:` starts *locked* — you see its
    # output, your keys do not reach it, so a stray Ctrl-C cannot kill it. Set
    # it explicitly to override: `false` to be able to type in it right away,
    # `true` to lock a plain shell.
    locked: false

  - name: web
    group: services
    cwd: ./web
    command: npm run dev

  - name: shell          # no group: shown under "ungrouped", at the bottom

A session that declares a command: starts locked, unless it says locked: false. Locked means the output panel shows it but does not forward your keystrokes to it: you can scroll, search and copy, and a mistyped q or a Ctrl-C meant for something else cannot kill the command. i or Enter takes the keyboard back, the prefix key (Ctrl-O) or Esc Esc gives it up again — and lazyshell remembers, per session, whichever you chose last, so moving through the list with j/k lands you in the state each session was left in. A bare shell declares no command and so starts ready to type in.

Groups are a display grouping — see Groups. Declaring one here is optional and only fixes the order of the headers: a session may name a group this block does not list, and it simply sorts after the declared ones. A bad groups: entry (empty or duplicate name) is dropped and reported like a bad session, and the rest still apply.

Field reference

KeyTypeDefaultEffect
shellstring""Overrides the user config's shell, for this project only.
env_files[]string[].env-style files loaded, in order, for every session this project declares — before each session's own env_files, and before its inline env.
no_default_envboolfalseDisables the automatic <session cwd>/.env lookup for every declared session, unless a session's own no_default_env overrides it back on.
groupslist of {name}[]Declares this project's groups and the order their headers appear in the sessions panel. A session may name a group not listed here; it simply sorts after the declared ones.
sessionslist of session entries[]Started in file order; the first one is selected.
sessions[].namestring, requiredMust be non-empty and unique; an invalid entry is skipped and reported, the others still start.
sessions[].groupstring"" (ungrouped)Need not be declared in groups:.
sessions[].cwdstringthis file's own directoryResolved relative to this file, not to where you launched lazyshell from. ~ is expanded.
sessions[].commandstring"" (bare shell)Typed into the shell once it is up, not exec'd in its place: when it exits (or you Ctrl-C it), the shell is still there.
sessions[].envmap[string]string{}Always wins, over every .env file layer (see below).
sessions[].env_files[]string[]On top of the project's own env_files, for this session only.
sessions[].no_default_envboolinherits the project's settingOverrides it for this session only, in either direction.
sessions[].watchlist of {pattern, notify}[]A regex evaluated against each output line, and whether a match notifies. Toggle one on the fly with v.
sessions[].restartnever | on-failure | alwaysneverRestarts the shell automatically when the command exits, with a delay that doubles each consecutive attempt (1s, 2s, 4s… capped at 60s), reset once a restarted run stays up 10s. R (or W for the group) restarts right away, bypassing the wait.
sessions[].stop_on_failureboolfalseKills the session outright as soon as command: exits non-zero, instead of leaving the shell open underneath. An explicit kill always wins over a pending restart:. No effect without a command:, reported on stderr if declared that way.
sessions[].lockedbooltrue if command: is declared, else falseAn explicit value always wins over the heuristic.

What is read, what is not

Only shell, env_files, no_default_env, groups and sessions are read from a project file. theme, keybindings, prefix_key and the rest stay under your control alone: a repository you cloned must not be able to remap your keyboard. Other keys are ignored, with a warning on stderr.

A group declares a name and nothing else — no colour, no glyph, no key. Same rule: a repository says what exists, not what your interface looks like. A session's watch: entries follow it too — a pattern and whether it notifies, nothing about how a match is shown. So does restart: — a policy and nothing else, no per-policy tuning of the backoff or a maximum-attempts knob. stop_on_failure: is the one exception to "the shell is still there" documented above: paired with restart:, an explicit kill always wins over a pending automatic restart, the same rule systemctl stop gives Restart=on-failure. locked: is the one key that touches the interface at all, and it is allowed because what it protects is the declared process itself: the worst a file you cloned can do with it is make you press i.

.env files

Every session — declared in a project file or not — automatically loads a .env from its own working directory, if there is one. Layered on top, each overriding a key the previous layer set:

  1. <session cwd>/.env, automatic, unless disabled (see below)
  2. --env-file <path> (repeatable, applies to every session this run starts)
  3. the project's own env_files: (applies to every declared session)
  4. a session's own env_files: (that session only)
  5. that session's env: map — always wins, over every file

To stop the automatic <cwd>/.env lookup:

Which file is used

  1. --config-file <file> (-f)
  2. $LAZYSHELL_PROJECT_CONFIG
  3. ./lazyshell.yml
  4. ./.lazyshell.yml

Only the current directory is searched — no walking up to a repository root, so the file that runs is always the one you can see.

Approving a project file

A lazyshell.yml is versioned in a repository, so it would otherwise run arbitrary commands the moment you cd into a clone. lazyshell asks once, before the interface opens, and remembers the answer per file — and asks again as soon as the file's content changes.

$ lazyshell allow            # approve the current directory's file, launch nothing
$ lazyshell allow ./x.yml    # approve a specific file
$ lazyshell --no-autostart   # open the interface without starting anything
$ lazyshell --env-file .env.prod   # extra .env file, for every session this run starts
$ lazyshell --no-env-file          # skip the automatic "<cwd>/.env"

Approvals live in trust.yml next to your user config. When stdin is not a terminal, approval is refused rather than assumed.

Layout persistence

When you quit, lazyshell saves each session's name, group, working directory and launch command for the current directory to ~/.config/lazyshell/state/<hash-of-the-directory>.yml — not anything live inside the shell, just the recipe it was started with. Launch it again from that same directory with no lazyshell.yml present, and it offers to restore that layout instead of starting the usual lone default session.

restore_layout controls what "offers" means: ask (the default) shows a confirmation popup naming the sessions it would recreate; always restores them with no prompt; never never offers it, though the layout keeps being saved regardless — so switching back to ask/always later still finds it. Declining the popup leaves the session list empty, the same place --no-autostart leaves you, with n right there to start one by hand.

A lazyshell.yml in the directory always wins: its declared sessions are what starts, and the saved layout is never even read, only kept up to date in case the file is later removed.