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
- Zero-config. The file is optional; every setting has the default
the binary ships with.
brew install→stormlightmust keep working with no file present. - One precedence rule everywhere:
flags > environment > config file > built-in defaults. - 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.
- 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()→Configstruct 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 configprints the effective merged config and the file path;stormlight config initwrites a fully commented template;stormlight config providersprints what each provider runs per permission mode, shell-quoted, one continuation line per flag with its value or per positional (--jsonfor 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.