Configuration

The design rationale behind config.toml — internal/config, stormlight config, and stormlight config init. The README's Configuration section is the user-facing reference.

What config is — and is not — for

A workspace is a directory. How agents behave inside it belongs to the tree itself (CLAUDE.md / AGENTS.md, read natively by the provider CLIs), and what groups directories into one workspace belongs to resolvers. The config file therefore holds only user preferences about Stormlight's own behavior: which provider and permission mode to reach for, how the UI looks, where the logs go. It never defines workspace semantics and never injects agent context.

Principles

  1. Zero-config. The file is optional; every setting has the default the binary ships with. brew install → stormlight must keep working with no file present.
  2. One precedence rule everywhere: flags > environment > config file > built-in defaults.
  3. Config never breaks determinism. Config keys select among behaviors Stormlight owns (which mode, which provider, which binary) — they do not open the door to arbitrary per-machine drift in how managed agents run.
  4. Config cannot silently escalate permissions. Nothing a repository checkout contains may raise an agent's permission mode (see "Project-local config" below).

File location

~/.config/stormlight/config.toml, honoring $XDG_CONFIG_HOME. Everything Stormlight owns lives under one directory each for config and state — dev-tool convention (git, gh, nvim do the same on macOS), and one directory to document:

~/.config/stormlight/
  config.toml     # user configuration
  resolvers/      # executable workspace resolvers
  actions/        # executable dashboard actions
~/.local/state/stormlight/
  workspaces.json # workspace catalog
  sessions.jsonl  # session history log

Format

TOML, parsed with pelletier/go-toml/v2. Rationale: comments (a config file that can't explain itself is a bug), obvious sectioning, no significant whitespace, the standard choice in the Go/Homebrew tooling ecosystem. JSON has no comments; YAML has too many footguns for a file this small.

Schema

# ~/.config/stormlight/config.toml — all keys optional.

[defaults]
provider = "claude"            # codex | claude | shell
mode     = "edits"             # ask | edits | auto

[ui]
rows = "compact"               # compact | expanded

[log]
level = "info"                 # debug | info | warn | error
# file = "…"                   # overrides the default log location

[tools]
# yazi = "/opt/homebrew/bin/yazi"   # override PATH lookup
# nvim = "/opt/homebrew/bin/nvim"

# Per-workspace overrides, keyed by workspace root.
[workspaces."/Volumes/repos/stormlight"]
mode     = "auto"
provider = "claude"

# Provider adapter tweaks. extra_args append after Stormlight's own
# flags, so they can refine but not remove lifecycle hooks. A block
# named for a built-in provider tunes it — binary, label, extra_args —
# and never replaces it: args and mode_args here are ignored with a
# warning.
[providers.codex]
# binary     = "codex"
# extra_args = ["--model", "o4"]

# User-defined providers. args is an exec-style array with a {task}
# placeholder — never a shell string.
[providers.aider]
# label  = "Aider"
# binary = "aider"
# args   = ["--message", "{task}"]
# [providers.aider.mode_args]
# auto = ["--yes-always"]

# How a machine differs from its name. A host does not have to appear
# here to be used — naming one is enough, and ssh already knows what
# a name means. This is for a host that needs something other than
# the default.
[hosts.devbox]
# destination  = "trent@10.0.0.4"   # defaults to the key
# bin          = "/opt/bin/stormlight"  # when it is not on PATH
# options      = ["-p", "2222"]     # extra ssh flags
# shell        = "/opt/homebrew/bin/fish"  # the shell providers are
#                                          # found and run in
# no_multiplex = false              # sharing is on by default

A host joins the dashboard as another daemon: its agents appear in the same roster, its workspaces in the same pane, and dispatch --host starts agents there. Hosts connect lazily and are retried on their own schedule, so one that is asleep costs the dashboard its absence and nothing else.

A host does not have to be configured to be used. Naming one is enough — in --host, in a workspace you add, or by picking it out of ~/.ssh/config — because ssh already knows what a name means. This table is for the machines that need something other than the default: destination when the name is not what ssh should dial, bin when Stormlight is somewhere a non-interactive shell will not find it, options for extra flags.

The host’s execution shell

shell is the one setting about a host that is not about reaching it. It names the login shell that defines what “installed” means there.

The default is the account’s own $SHELL, which is right whenever the shell someone works in is the shell their account records. It stops being right the moment those differ — an account whose $SHELL is /bin/zsh belonging to someone who has configured fish, whose PATH is the only one that finds codex. Nothing about the machine reveals which shell was meant, so the setting says it outright; Stormlight does not scan for installed shells and guess. The path must be absolute, for the same reason the setting exists: the PATH a non-interactive SSH session gets is exactly the PATH that will not find the shell by name.

The shell is used for both halves of running a provider, because using it for only one is worse than using it for neither. Discovery — “is codex installed here” — is asked of that shell, so a provider present only on its PATH is found. Launch goes through it, so the provider inherits the environment and not merely the path: a provider found on a login shell’s PATH is regularly a shim — mise, asdf, a Homebrew wrapper — that needs the rest of that shell’s environment to work at all. Resolving it to an absolute path and then running it under the daemon’s bare environment finds the right file and still fails.

The launch is three exec calls and no wrapper process, so what the daemon supervises is the provider itself — signals, exit status, and lifetime untouched by the route taken to start it. The provider’s argv never appears in shell text: it travels in an environment variable as JSON and is decoded on the far side, because task text and custom provider arguments are arbitrary input and the shell that would parse them is one this machine does not choose.

A configured shell that is missing or not executable is reported as that — naming the setting and the host — rather than as every provider on the host having gone missing, which is what the symptom otherwise looks like.

stormlight remote setup <host> answers the question rather than leaving it to be discovered by failure. It asks the host which of its login shells can see the configured providers — the account’s own, whatever /etc/shells registers, and a few names that may be installed without being registered — and prints the result side by side:

  providers, by the shell that can see them:
  * /opt/homebrew/bin/fish             claude
    /bin/bash                          claude codex
    /bin/zsh                           claude
  * the account's own shell, used unless [hosts.mini] names another

When the account’s shell sees strictly fewer providers than another, setup records shell for that host and says so. This is evidence rather than a guess: the machine was asked, and it answered. A host whose account shell can see everything is left alone — a host that works is not improved by being configured — and a shell someone has already set by hand is never overwritten. The survey costs a login shell per candidate, so it runs only when there are providers to ask about; the dashboard’s own reachability check skips it.

Custom providers appear in the New Agent picker and dispatch like any other. Lifecycle integration is the public contract: managed processes receive STORMLIGHT_ID and can report state with stormlight event from any hook mechanism the CLI provides. Stormlight never reimplements an agent.

Wiring

  • internal/config: config.Load() → Config struct with the defaults filled in, called once before command construction.
  • Cobra flag defaults are initialized from the loaded config, so the precedence rule falls out of the existing machinery instead of being reimplemented per flag.
  • Validation errors name the key and file (config.toml: defaults.mode: invalid permission mode "always"), and a broken config falls back to defaults with a visible warning rather than refusing to start — the dashboard is also how you'd notice the problem.
  • stormlight config prints the effective merged config and the file path; stormlight config init writes a fully commented template; stormlight config providers prints what each provider runs per permission mode, shell-quoted, one continuation line per flag with its value or per positional (--json for the structured form). The effective TOML cannot show this — a built-in provider has no block there unless the user wrote one, and the block shows the tuning rather than the command.

Project-local config (deliberately deferred)

A .stormlight.toml in a repo root ("this project defaults to codex") is attractive for teams but dangerous: a cloned repository must never be able to grant itself mode = "auto". If it is added later, it needs direnv-style trust ("stormlight noticed project config, run stormlight trust to apply") and a hard rule that permission mode can only be lowered by untrusted project files. For now, overrides live in the user's own config file, keyed by path.