ADR-0016: Cobra for the command tree, Viper for process config, and a HARNESS_* environment layer
Context and Problem Statement
Harness has no environment-variable configuration. Every process-level setting —
socket path, config path, log level, log file, scrollback depth, the SSH server
and its listen address — is reachable only through a command-line flag or the
TOML file. The only environment variables the binary reads are TERM, TMUX, the XDG_*
set — path and terminal discovery, not configuration — and one internal marker,
HARNESS_DETACH_READY_FD, which detachDaemon sets on the forked child so it
knows to signal readiness back to the parent. That marker is the sole existing
inhabitant of the HARNESS_* namespace, and it is IPC plumbing rather than
anything an operator sets.
That makes Harness awkward in exactly the places a supervisor belongs. A systemd unit has to carry a flag string; a container image has to bake or mount a TOML file to move a socket; a test harness cannot point one invocation at a scratch socket without rewriting argv. This is the "config in the environment" factor of the twelve-factor app, and Harness fails it.
The gap was easy to miss because it looks like a solved problem — most Go CLIs
get this from Viper. Harness has neither Viper nor Cobra: cmd/harness/main.go
uses the standard library's flag package and internal/config is a
hand-rolled BurntSushi/toml loader. Nothing was ever going to bind an
environment variable automatically.
So: how should Harness read configuration from the environment, and does adopting Cobra + Viper to get it cost more than it returns?
Decision Drivers
- Twelve-factor. Config that varies between deploys — where the socket lives, how loud the log is, whether the SSH server is on — belongs in the environment, not in argv and not baked into an image.
- One obvious precedence order. A setting reachable three ways needs a single documented ranking, or every support question becomes "which one won?"
- The flag parser is already a liability.
parseInterleavedincmd/harness/main.goexists solely because stdlibflaghalts at the first positional, soharness logs ticker --lines 3needs a hand-rolled parse/peel/re-parse loop. The daemon subcommand tree is a nestedswitchonrest[0]with its own hand-written usage text. - Line-numbered config errors are load-bearing.
internal/configreports every parse and validation failure as aconfig.Errorcarrying a 1-based source line, because SPEC-0001 REQ "Zero And Error States" renders "using last-good config; line 12: …" in the reload banner. Whatever reads the TOML must keep producing that. - The existing schema must not churn. 📝 ADR-0006 fixed
[harness.*],[profile.*],[daemon],[server]and made the file the source of truth with hot reload. This decision extends that surface; it does not reopen it. - Secrets stay out. 📝 ADR-0008 keeps credentials in
env_file, not in config. An environment layer must not become a second, more tempting place to put a token.
Considered Options
- Option 1: Cobra for commands + Viper for process settings, BurntSushi retained for the domain tables (chosen)
- Option 2: Viper for everything, including
[harness.*]and[profile.*] - Option 3: Hand-rolled
os.Getenvlayer, keep stdlibflag - Option 4: Cobra only — subcommands, but no environment layer
Decision Outcome
Chosen option: Option 1. Cobra owns the command tree, Viper owns the
precedence ladder for process settings only, and BurntSushi/toml keeps
parsing the domain tables that carry line numbers.
The two libraries do different jobs and the split is the whole point:
| Concern | Owner | Why |
|---|---|---|
| Subcommands, flags, help, completion | Cobra | Replaces parseInterleaved and the nested switch; positional-after-flag works natively |
| Precedence: flag → env → file → default | Viper | BindPFlag + SetEnvPrefix("HARNESS") + AutomaticEnv is the entire env layer |
[harness.*], [profile.*] parsing/validation | BurntSushi/toml (unchanged) | Viper lowercases keys, flattens structure, and discards toml.MetaData — the reload banner's line numbers would go with it |
Precedence, stated once: an explicit flag beats HARNESS_*, which beats the
TOML file, which beats the compiled default. "Explicit" means the user actually
typed it — Cobra's Flags().Changed(name) — not merely that the flag has a
value.
Scope of the environment layer. Every process-level scalar, and nothing else:
| Variable | Replaces / backs | Default |
|---|---|---|
HARNESS_SOCKET | --socket | $XDG_RUNTIME_DIR/harness.sock |
HARNESS_CONFIG | --config | $XDG_CONFIG_HOME/harness/harness.toml |
HARNESS_JSON | --json | false |
HARNESS_LOG_LEVEL | --log-level | info |
HARNESS_LOG_FILE | --log-file | (stderr) |
HARNESS_SCROLLBACK | --scrollback | attach.DefaultRingLines |
HARNESS_SSH | --ssh, [server].enabled | false |
HARNESS_SSH_LISTEN | --ssh-listen, [server].listen | (unset) |
HARNESS_WATCH_CONFIG | [daemon].watch_config | true |
[harness.*] and [profile.*] stay file-only. They are collections, not
scalars, and encoding a map into environment variables means inventing a
name-mangling scheme (HARNESS_HARNESS_CLAUDE_SRC_WORKDIR — is CLAUDE_SRC the
harness claude-src or claude_src?) that no reader can invert reliably. The
twelve-factor argument is about config that varies between deploys; which
harnesses exist is the application, not its deployment.
The line this draws is deliberate and worth stating plainly: a container can run a fully-configured daemon with no TOML file at all, but it cannot define a harness without one.
Consequences
- Good, because a systemd unit or container configures the daemon with
Environment=lines instead of an argv string or a bind-mounted file. - Good, because
parseInterleavedand the hand-rolled daemonswitchboth delete — Cobra does positionals-after-flags and subcommand dispatch natively. - Good, because shell completion and
harness help <verb>come from Cobra for free, where today the usage text is hand-maintained inusage(). - Good, because precedence is stated in one place and testable as one table.
- Bad, because it adds two sizeable dependencies to a tree 📝 ADR-0001 kept
deliberately small. Measured, not estimated: the module graph goes from 52 to
66 entries. Cobra brings
pflagandmousetrap; Viper bringsafero,cast,mapstructure,locafero,conc,gotenv, a second TOML parser (pelletier/go-toml, alongside theBurntSushi/tomlalready in use), and a YAML parser this project will never call. - Bad, because the CLI surface is rewritten, and a rewrite of argv handling is exactly where regressions hide — every verb, flag, and the interleaved positional behaviour need tests before the swap, not after.
- Bad, because two config readers now coexist (Viper for scalars, BurntSushi for tables), which is more machinery than one. The reload banner's line numbers are worth it, but it is a real seam and it must be documented so the next reader does not "unify" it by accident.
- Neutral, because
HARNESS_*becomes a namespace with a rule: it is for process settings, and it is not where secrets go (📝 ADR-0008 still owns those viaenv_file). The one name already in use,HARNESS_DETACH_READY_FD, is internal and is reserved rather than reused.
Confirmation
HARNESS_SOCKET=/tmp/x.sock harness lsdials/tmp/x.sock; adding--socket=/tmp/y.sockdials/tmp/y.sock— flag beats env.HARNESS_LOG_LEVEL=debug harness daemon runstarts at debug with no flag and no[daemon]table present.- A daemon starts and serves with no
harness.tomlon disk when every setting is supplied through the environment. - A malformed
[harness.*]table still producesconfig.Errorwith a non-zeroLineNumber(), and the TUI reload banner still rendersline N:— proven by the existinginternal/configtests, which must pass unchanged. harness logs ticker --lines 3andharness logs --lines 3 tickerboth work after the Cobra migration (theparseInterleavedcontract, preserved), andharness --lines 3 logs tickerstill fails —--linesis verb-local, and Cobra must not silently promote it to a persistent root flag.harness doctorreports which source won for each process setting, so "which one won?" is answerable without reading code.
Pros and Cons of the Options
Option 1: Cobra + Viper for process settings, BurntSushi for domain tables
- Good, because each library does the job it is actually good at
- Good, because the environment layer is ~20 lines of
BindPFlagrather than a hand-maintainedos.Getenvladder per setting - Good, because line-numbered validation errors survive untouched
- Neutral, because it accepts two config readers as the price of that
- Bad, because it is the largest dependency addition in the project's history: +14 modules, including a second TOML parser and a YAML parser that are dead weight here
Option 2: Viper for everything
- Good, because there is exactly one config reader
- Good, because full twelve-factor purity — no file needed for anything
- Bad, because Viper discards
toml.MetaData, so the SPEC-0001 reload banner loses its line numbers and degrades to "config is broken somewhere" - Bad, because Viper lowercases and flattens keys, which breaks the 📝 ADR-0006
bare-
[name]-table backward compatibility and the absent-vs-false distinction thatrawHarness.Enabled *boolrelies on - Bad, because it requires a name-mangling scheme for harness names that cannot be reliably inverted
Option 3: Hand-rolled os.Getenv layer, keep stdlib flag
- Good, because zero new dependencies
- Good, because it could ship in an afternoon
- Neutral, because the precedence logic is genuinely simple for nine settings
- Bad, because
parseInterleavedand the hand-written usage/subcommand dispatch all survive, and they are the actual maintenance burden - Bad, because every new setting means remembering to add a fourth branch by hand, which is precisely the kind of thing that silently rots
Option 4: Cobra only, no environment layer
- Good, because it fixes the flag parsing without a config rewrite
- Bad, because it does not solve the stated problem at all — the twelve-factor gap remains
Architecture Diagram
More Information
-
Extends 📝 ADR-0006, which made the TOML file the source of truth with hot reload. This adds a layer above the file for process settings and leaves the file authoritative for everything it already owned.
-
Related to 📝 ADR-0005 (the daemon is normally supervised by init —
Environment=in the unit file is the intended delivery mechanism), 📝 ADR-0008 (secrets stay inenv_file;HARNESS_*is not a secret channel), 📝 ADR-0009 (project-scopedharness.tomldiscovery is unaffected —HARNESS_CONFIGnames the global file), and 📝 ADR-0001 (which chose the small-dependency Go/Charm stack this decision deliberately grows). -
SPEC-0010 formalizes the variable table, the precedence rules, and the parse/validation behaviour for malformed values.
-
SPEC-0009 REQ "Reduced Motion" deferred its toggle to "a config key on the 📝 ADR-0006 config surface, with an env override reserved for a later revision." This is that revision; the chatroom toggle lands as
HARNESS_REDUCED_MOTIONunder the same rules once the chatroom itself is built. -
This is a house pattern, not a novel decision. Two sibling repos already made it, and both are worth reading before revisiting this one:
msgbrowse📝 ADR-0009 "Configuration & CLI — Cobra (commands) + Viper (config)" reaches the same two conclusions independently: precedencedefaults < file < env < flags, andpflag.Changed-aware binding as the mechanism, for the same reason given above ("a flag only wins when typed"). It also carries a 2026-07 amendment softening its secrets rule for a desktop UI — a useful precedent if Harness ever grows a settings surface, though 📝 ADR-0008 keeps credentials out ofHARNESS_*here.joe-links📝 ADR-0004 "CLI Framework — Cobra + Viper with JOE_ Environment Variable Prefix" takes the lighter path:SetEnvPrefix+AutomaticEnvwith no explicit registry. That is the right trade for a service whose settings are all plain scalars with no empty-vs-unset subtlety; Harness needs the explicit table because SPEC-0010 requires empty-is-absent and an enumerable set fordoctorattribution.
The convergence is the point: three repos, three independent passes, same precedence order. Diverging from it here would need a specific reason.
-
Migration is staged deliberately: characterization tests over today's CLI behaviour first, then the Cobra swap, then Viper. Landing all three at once makes a bisect useless.
Related Artifacts
Direct relationships declared in YAML frontmatter (per ADR-0023 / SPEC-0018). Run /sdd:graph chain ADR-0016 for the transitive view.