SPEC-0010: CLI Command Tree and Environment Configuration
Overview
Harness resolves its process settings — socket path, config path, log level,
log file, scrollback budget and depth, SSH server enable and listen address,
config watching, sealed-log compression, and JSON output — from four sources
ranked in one fixed order: an explicit command-line flag, a HARNESS_*
environment variable, the TOML file, then the compiled default.
The command tree is built with Cobra; the precedence ladder is resolved by Viper
bound to Cobra's flags. The domain tables ([harness.*], [profile.*])
continue to be parsed by the existing internal/config loader over
BurntSushi/toml, because their validation errors carry source line numbers
that SPEC-0001 REQ "Zero And Error States" renders in the reload banner.
See 📝 ADR-0016 for the decision, including why the environment layer deliberately stops at scalars and does not extend to harness or profile definitions.
This spec extends SPEC-0003, which owns the 📝 ADR-0006 config surface. It adds a resolution layer above the file for process settings and changes nothing about what the file means.
Requirements
Requirement: Environment Variable Namespace
Harness SHALL read process settings from environment variables in the
HARNESS_ namespace. The variable name SHALL be HARNESS_ followed by the
corresponding flag name uppercased with hyphens replaced by underscores.
The following variables SHALL be recognized:
| Variable | Flag | File key | Type | Default |
|---|---|---|---|---|
HARNESS_SOCKET | --socket | [daemon] socket | path | $XDG_RUNTIME_DIR/harness.sock |
HARNESS_CONFIG | --config | (none) | path | $XDG_CONFIG_HOME/harness/harness.toml |
HARNESS_JSON | --json | (none) | bool | false |
HARNESS_LOG_LEVEL | --log-level | [daemon] log_level | enum | info |
HARNESS_LOG_FILE | --log-file | [daemon] log_file | path | (stderr) |
HARNESS_SCROLLBACK | --scrollback | [daemon] scrollback | int | attach.DefaultRingLines |
HARNESS_SCROLLBACK_BYTES | --scrollback-bytes | [daemon] scrollback_bytes | size | attach.DefaultRingBytes (1 MiB) |
HARNESS_SSH | --ssh | [server] enabled | bool | false |
HARNESS_SSH_LISTEN | --ssh-listen | [server] listen | host:port | (unset) |
HARNESS_WEBHOOK_LISTEN | --webhook-listen | [server] webhook_listen | host:port | (unset) |
HARNESS_WATCH_CONFIG | (none) | [daemon] watch_config | bool | true |
HARNESS_COMPRESS_LOGS | --compress-logs | [daemon] compress_logs | bool | true |
HARNESS_MEMORY_LIMIT | --memory-limit | [daemon] memory_limit | size | (unset: GOMEMLIMIT, else off) |
HARNESS_PPROF_ADDR | --pprof-addr | [daemon] pprof_addr | loopback host:port | (unset: off) |
Every file key in this table SHALL be accepted by the config file loader. A key
that the settings layer reads but the loader rejects as unknown makes the whole
file fail to load, so the file source it names can never be used. In the file,
socket and log_file SHALL be absolute paths and scrollback SHALL be at
least 1, memory_limit SHALL be a size (REQ "Go Memory Limit"), and
pprof_addr SHALL be empty or a loopback host:port (SPEC-0013 REQ-8). The
loader SHALL reject any other value with the key's line number.
scrollback_bytes SHALL be between 64 KiB and 1 GiB from every source: the
loader SHALL reject a file value outside that range with its line number, and
the daemon SHALL refuse to start on a flag, environment or file value outside
it, naming the source.
config has no file key because it names the file. json has none because it
selects the output format for one invocation.
compress_logs controls whether the daemon compresses sealed logs to
<file>.zst: rotated backups of each durable log, and each closed run's log
and raw stream (SPEC-0003 REQ "Durable Log Rotation And Compression", SPEC-0008
REQ "Per-Run Logs"). Setting it false stops new compressions. Files already
compressed stay readable.
Harness MUST NOT read harness or profile definitions from the environment. No
HARNESS_* variable SHALL define, modify, or remove a [harness.*] or
[profile.*] table.
HARNESS_DETACH_READY_FD is RESERVED. It predates this spec as internal IPC
between harness daemon --detach and its forked child, it is not operator
configuration, and it MUST NOT be reassigned or documented as a setting.
HARNESS_RUN_ID, HARNESS_RUN_TRIGGER, HARNESS_RUN_SOURCE and
HARNESS_EVENT_FILE are RESERVED. The daemon sets them in the environment of a
triggered run (SPEC-0014 REQ "Event Delivery To The Run"). They are not
settings, Harness SHALL NOT read them as configuration, and no flag SHALL be
named so that it maps onto them.
Scenario: Environment supplies the socket
- WHEN
HARNESS_SOCKET=/tmp/x.sockis set and no--socketflag is given - THEN the client SHALL dial
/tmp/x.sock
Scenario: Unset variable falls through
- WHEN
HARNESS_LOG_LEVELis unset and no--log-levelflag is given and no[daemon]table supplies a level - THEN the daemon SHALL start at the compiled default level
info
Scenario: Empty variable is not a value
- WHEN
HARNESS_SOCKET=""is set (present but empty) - THEN the setting SHALL fall through to the next source in precedence, and the empty string SHALL NOT be used as a socket path
Scenario: Reserved internal name is not a setting
- WHEN the recognized-variable table is enumerated for documentation or for
harness doctorsource attribution - THEN
HARNESS_DETACH_READY_FDSHALL NOT appear as a configurable setting
Scenario: Harness definitions are file-only
- WHEN an operator sets
HARNESS_HARNESS_TICKER_CMD=sleepand no[harness.ticker]table exists in the config file - THEN no harness named
tickerSHALL be registered, and the variable SHALL be ignored without error
Requirement: Precedence Order
Harness SHALL resolve each process setting from the first source that supplies it, in this order: explicit flag, then environment variable, then config file, then compiled default.
A flag SHALL be treated as explicit only when the user supplied it on the
command line — determined by the flag's Changed state, not by whether it holds
a non-zero value. A flag left at its declared default MUST NOT suppress an
environment variable.
Scenario: Flag beats environment
- WHEN
HARNESS_SOCKET=/tmp/x.sockis set and the user runsharness --socket /tmp/y.sock ls - THEN the client SHALL dial
/tmp/y.sock
Scenario: Environment beats file
- WHEN the config file sets
[daemon]watch_config = falseandHARNESS_WATCH_CONFIG=1is set, with no flag given - THEN the daemon SHALL watch the config file
Scenario: File beats default
- WHEN the config file sets
[server]listen = "127.0.0.1:2222", noHARNESS_SSH_LISTENis set, and no--ssh-listenflag is given - THEN the SSH server SHALL bind
127.0.0.1:2222
Scenario: Daemon table supplies scrollback
- WHEN the config file sets
[daemon]scrollback = 2000, noHARNESS_SCROLLBACKis set, and no--scrollbackflag is given - THEN the daemon SHALL start, and each harness's scrollback ring SHALL hold 2000 lines
Scenario: Daemon table supplies the scrollback budget
- WHEN the config file sets
[daemon]scrollback_bytes = "4MiB", noHARNESS_SCROLLBACK_BYTESis set, and no--scrollback-bytesflag is given - THEN the daemon SHALL start, and each harness's scrollback ring SHALL use at most 4 MiB of storage
Scenario: Daemon table supplies the socket to daemon and client
- WHEN the config file sets
[daemon]socket = "/run/harness/h.sock"and neitherHARNESS_SOCKETnor--socketis given - THEN the daemon SHALL bind
/run/harness/h.sock, and a client reading the same file SHALL dial it
Scenario: Unchanged flag does not mask the environment
- WHEN
HARNESS_LOG_LEVEL=debugis set and the user runsharness daemon runwith no--log-levelflag, where--log-leveldeclares a default ofinfo - THEN the daemon SHALL start at
debug
Requirement: Fileless Operation
The daemon SHALL start and serve when no config file exists at the resolved config path, provided every required process setting is available from a flag, an environment variable, or a default.
A missing config file SHALL NOT be treated as an error. A config file that exists but cannot be parsed SHALL remain an error, per SPEC-0003.
Scenario: Container with no TOML
- WHEN the daemon is started with
HARNESS_SOCKET,HARNESS_LOG_LEVEL, andHARNESS_SSH_LISTENset, and no file exists at the resolved config path - THEN the daemon SHALL bind its socket, start the SSH server, and report zero configured harnesses
Scenario: Malformed file is still fatal
- WHEN a file exists at the resolved config path and fails to parse
- THEN the daemon SHALL report the parse error with its source line and SHALL NOT silently fall back to an empty configuration
Requirement: Environment Value Validation
Harness SHALL validate environment-supplied values with the same rules applied to the corresponding flag. A malformed value SHALL produce an error naming the variable, the offending value, and the accepted form.
An invalid environment value MUST NOT be silently coerced, ignored, or replaced with the default.
Booleans SHALL accept 1, 0, true, false, yes, no, on, and off,
case-insensitively.
Sizes SHALL accept a whole number of bytes, optionally followed by a unit:
B, KiB, MiB, GiB or TiB, with K/KB/Ki, M/MB/Mi,
G/GB/Gi and T/TB/Ti meaning the same 1024-based units,
case-insensitively. A TOML integer in the file counts bytes.
Scenario: Scrollback budget out of range
- WHEN
HARNESS_SCROLLBACK_BYTES=16GiBis set - THEN startup SHALL fail with an error naming
HARNESS_SCROLLBACK_BYTES, the value, and the accepted range of 64 KiB to 1 GiB
Scenario: Non-numeric scrollback
- WHEN
HARNESS_SCROLLBACK=lotsis set - THEN startup SHALL fail with an error naming
HARNESS_SCROLLBACK, the valuelots, and that an integer is required
Scenario: Unknown log level
- WHEN
HARNESS_LOG_LEVEL=verboseis set - THEN startup SHALL fail with an error naming the variable and listing the
accepted levels
debug, info, warn, error
Scenario: Boolean spellings
- WHEN
HARNESS_SSH=onis set - THEN the SSH server SHALL be enabled, identically to
HARNESS_SSH=1
Requirement: Source Attribution
harness doctor SHALL report, for each process setting, its resolved value and
which source supplied it (flag, env, file, or default).
Values SHALL be reported in full except where the setting is a path that does not exist, which SHALL additionally be flagged.
Scenario: Doctor names the winning source
- WHEN
HARNESS_SOCKET=/tmp/x.sockis set and the user runsharness doctor - THEN the report SHALL show the socket setting with value
/tmp/x.sockand sourceenv
Scenario: Doctor JSON carries the source
- WHEN the user runs
harness doctor --json - THEN each process setting SHALL appear with both its value and its source as machine-readable fields
Requirement: Command Tree
The CLI SHALL be built as a Cobra command tree. Every verb available before this spec SHALL remain available with the same name, the same flags, and the same output contract.
Flags SHALL be scoped as they are today. --socket, --config, and --json
are global and SHALL be accepted before the verb; --json SHALL additionally be
accepted after it. Per-verb flags (--lines, --follow, --ro, --all) are
local to their verb and MUST NOT be accepted before it.
After the verb, a per-verb flag and the single positional argument SHALL be
accepted in either order, so harness logs ticker --lines 3 and
harness logs --lines 3 ticker behave identically.
harness with no verb SHALL open the TUI. harness daemon with no subcommand
SHALL behave as harness daemon run.
Scenario: Interleaved flag and positional after the verb
- WHEN the user runs
harness logs ticker --lines 3 - THEN the client SHALL request 3 trailing lines for the harness
ticker - WHEN the user runs
harness logs --lines 3 ticker - THEN the client SHALL make the identical request
Scenario: Verb-local flag before the verb is rejected
- WHEN the user runs
harness --lines 3 logs ticker - THEN the CLI SHALL exit non-zero with a flag-parse error, because
--linesis local tologs
Scenario: Global flag placement
- WHEN the user runs
harness --json listorharness list --json - THEN both SHALL produce machine-readable output
Scenario: Bare daemon verb
- WHEN the user runs
harness daemonwith no subcommand - THEN the daemon SHALL start, identically to
harness daemon run
Scenario: Unknown verb
- WHEN the user runs
harness frobnicate - THEN the CLI SHALL exit non-zero with an error naming the unknown verb and
pointing at
harness --help
Requirement: Go Memory Limit
The daemon SHALL apply a Go soft memory limit (runtime/debug.SetMemoryLimit)
from --memory-limit, HARNESS_MEMORY_LIMIT, or [daemon] memory_limit,
resolved by the Precedence Order above. It SHALL be applied at daemon start and
SHALL take effect again only on a restart, like every process setting.
The value is a size, in the form REQ "Environment Value Validation" defines
(the 1024-based units match systemd's MemoryMax=), so a bare number is
bytes. 0 SHALL mean no limit.
GOMEMLIMIT is not in the HARNESS_ namespace. The Go runtime applies it
before the daemon's code runs. It SHALL rank below every source above and
above the default:
- an explicit
--memory-limit,HARNESS_MEMORY_LIMIT, or[daemon] memory_limitSHALL replace whatever limitGOMEMLIMITset, and an explicit0SHALL remove it; - with none of them set, the daemon SHALL leave the runtime's limit, and so
GOMEMLIMIT's, untouched; - with neither, there SHALL be no limit.
The harness settings win because they are addressed to the daemon.
GOMEMLIMIT is Go-wide. A shell profile or a platform may set it, and the
daemon's environment is inherited by every harness it spawns.
The default SHALL be off. A soft limit cannot free a live leak, and set below the live heap it keeps the GC running near-continuously. The right value depends on the host, which the daemon cannot see.
The daemon SHALL log, at startup, the limit in effect (read back from the
runtime) and its source: flag, env, file, GOMEMLIMIT, or default.
Scenario: File limit applied
- WHEN
[daemon] memory_limit = "2GiB"is set and no flag orHARNESS_MEMORY_LIMITis given - THEN the daemon's Go memory limit SHALL be 2 GiB, and
go_gc_gomemlimit_bytesSHALL report 2147483648
Scenario: Harness setting beats GOMEMLIMIT
- WHEN
GOMEMLIMIT=512MiBis in the daemon's environment and[daemon] memory_limit = "1GiB"is set - THEN the limit SHALL be 1 GiB, and the startup log SHALL say that it
overrides
GOMEMLIMIT
Scenario: GOMEMLIMIT beats the default
- WHEN
GOMEMLIMIT=512MiBis set and no harness source names a limit - THEN the limit SHALL stay 512 MiB, logged with source
GOMEMLIMIT
Scenario: A bare number is bytes
- WHEN
HARNESS_MEMORY_LIMIT=2048is set - THEN the limit SHALL be 2048 bytes, and the daemon SHALL warn that the limit is very small
Requirement: Secrets Exclusion
The HARNESS_* namespace SHALL NOT carry credentials. No variable in this spec
accepts a token, key, password, or credential value.
Per-harness secrets SHALL continue to be delivered through env_file as 📝 ADR-0008
requires. Documentation for this capability SHALL state this explicitly.
Scenario: No credential variables exist
- WHEN the recognized-variable table is enumerated
- THEN every entry SHALL be a path, boolean, integer, size, enum, or host:port, and none SHALL be a credential
Requirement: Error Handling Standards
All error-producing operations in configuration resolution MUST follow structured error handling:
- Errors MUST be wrapped with contextual information at each layer boundary, so a failure names both the setting and the source that supplied the bad value
- Sentinel errors MUST be defined for failure modes callers distinguish programmatically, including "config file absent" (non-fatal) as distinct from "config file unparseable" (fatal)
- Silent error swallowing MUST NOT occur — an invalid environment value MUST be returned to the caller, never coerced to a default
- Structured logging MUST be used for error reporting, with the variable name and source as key-value fields rather than interpolated prose
Scenario: Error names its source
- WHEN an invalid value arrives from
HARNESS_SCROLLBACK - THEN the resulting error SHALL identify the environment variable as the
source, distinguishing it from the same invalid value arriving via
--scrollback
Scenario: Absent file is distinguishable
- WHEN a caller loads configuration and no file exists at the resolved path
- THEN the caller SHALL be able to distinguish "absent" from "unparseable" programmatically, without string-matching the error text
Design
This spec is paired with the design notes, which covers the architecture, the library split, and the staged migration.
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain SPEC-0010 for the transitive view.