Skip to main content
Status:✏️ DRAFT
Date:📅 2026-08-19
Domain:📦 CLI Command Tree and Environment Configuration

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:

VariableFlagFile keyTypeDefault
HARNESS_SOCKET--socket[daemon] socketpath$XDG_RUNTIME_DIR/harness.sock
HARNESS_CONFIG--config(none)path$XDG_CONFIG_HOME/harness/harness.toml
HARNESS_JSON--json(none)boolfalse
HARNESS_LOG_LEVEL--log-level[daemon] log_levelenuminfo
HARNESS_LOG_FILE--log-file[daemon] log_filepath(stderr)
HARNESS_SCROLLBACK--scrollback[daemon] scrollbackintattach.DefaultRingLines
HARNESS_SCROLLBACK_BYTES--scrollback-bytes[daemon] scrollback_bytessizeattach.DefaultRingBytes (1 MiB)
HARNESS_SSH--ssh[server] enabledboolfalse
HARNESS_SSH_LISTEN--ssh-listen[server] listenhost:port(unset)
HARNESS_WEBHOOK_LISTEN--webhook-listen[server] webhook_listenhost:port(unset)
HARNESS_WATCH_CONFIG(none)[daemon] watch_configbooltrue
HARNESS_COMPRESS_LOGS--compress-logs[daemon] compress_logsbooltrue
HARNESS_MEMORY_LIMIT--memory-limit[daemon] memory_limitsize(unset: GOMEMLIMIT, else off)
HARNESS_PPROF_ADDR--pprof-addr[daemon] pprof_addrloopback 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.sock is set and no --socket flag is given
  • THEN the client SHALL dial /tmp/x.sock

Scenario: Unset variable falls through​

  • WHEN HARNESS_LOG_LEVEL is unset and no --log-level flag 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 doctor source attribution
  • THEN HARNESS_DETACH_READY_FD SHALL NOT appear as a configurable setting

Scenario: Harness definitions are file-only​

  • WHEN an operator sets HARNESS_HARNESS_TICKER_CMD=sleep and no [harness.ticker] table exists in the config file
  • THEN no harness named ticker SHALL 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.sock is set and the user runs harness --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 = false and HARNESS_WATCH_CONFIG=1 is 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", no HARNESS_SSH_LISTEN is set, and no --ssh-listen flag 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, no HARNESS_SCROLLBACK is set, and no --scrollback flag 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", no HARNESS_SCROLLBACK_BYTES is set, and no --scrollback-bytes flag 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 neither HARNESS_SOCKET nor --socket is 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=debug is set and the user runs harness daemon run with no --log-level flag, where --log-level declares a default of info
  • 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, and HARNESS_SSH_LISTEN set, 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=16GiB is 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=lots is set
  • THEN startup SHALL fail with an error naming HARNESS_SCROLLBACK, the value lots, and that an integer is required

Scenario: Unknown log level​

  • WHEN HARNESS_LOG_LEVEL=verbose is 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=on is 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.sock is set and the user runs harness doctor
  • THEN the report SHALL show the socket setting with value /tmp/x.sock and source env

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 --lines is local to logs

Scenario: Global flag placement​

  • WHEN the user runs harness --json list or harness list --json
  • THEN both SHALL produce machine-readable output

Scenario: Bare daemon verb​

  • WHEN the user runs harness daemon with 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:

  1. an explicit --memory-limit, HARNESS_MEMORY_LIMIT, or [daemon] memory_limit SHALL replace whatever limit GOMEMLIMIT set, and an explicit 0 SHALL remove it;
  2. with none of them set, the daemon SHALL leave the runtime's limit, and so GOMEMLIMIT's, untouched;
  3. 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 or HARNESS_MEMORY_LIMIT is given
  • THEN the daemon's Go memory limit SHALL be 2 GiB, and go_gc_gomemlimit_bytes SHALL report 2147483648

Scenario: Harness setting beats GOMEMLIMIT​

  • WHEN GOMEMLIMIT=512MiB is 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=512MiB is 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=2048 is 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.

Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain SPEC-0010 for the transitive view.