Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The SettingSpec promo

SettingSpec Check Crates.io Version PyPI Version NPM Version

SettingSpec

Don’t program your settings. Specify • Validate • Export - in any format.

  1. Install
  2. Basic usage
    1. Specify settings in a single file
    2. Validate the specification
    3. Export the settings
    4. Use the exported settings in your program
  3. Concepts
    1. Single file convenience
    2. Strict validation of per profiles settings
    3. Language independent
    4. Easy secrets
      1. SecretSpec
      2. Dotenv
      3. Cottage or agebox
  4. Learn more

Install

Directly download the binary.

Or install from a package registry:

# rust: cargo-binstall/cargo
cargo binstall --locked settingspec
cargo install --locked settingspec

# python: pip/uv/uvx
pip install settingspec
uv pip install settingspec
uvx settingspec --version

# node: yarn/pnpm/npx
yarn global add @sayanarijit/settingspec
pnpm add -g @sayanarijit/settingspec
npx @sayanarijit/settingspec --version

# container: docker/podman
docker run --rm -v $PWD:/app ghcr.io/sayanarijit/settingspec --version
podman run --rm -v $PWD:/app ghcr.io/sayanarijit/settingspec --version

Basic usage

Specify settings in a single file

Create a file named settingspec.toml at the project root.

# settingspec.toml

[spec]
profile.key = "ENV"
profile.options = ["local", "prod"]
profile.default = "local"

[settings]
HOST._ = {
    local.val = "127.0.0.1",
    prod.val = "0.0.0.0",
}
PORT._.default.val = 8080

The [spec] section is optional. If not present, the default specification is used.

The [settings] section contains the settings in {key}._.{profile}.{directive} = {value} syntax.

Validate the specification

settingspec check

Ensures that the declared settings are valid and consistent with the specification, no key is missing declaration for any of the declared profiles, among other checks.

Export the settings

# export ENV=prod
settingspec export

Use the exported settings in your program

my-app -c settings.toml

my-app will use the following settings:

HOST = "127.0.0.1"
PORT = 8080

As per our expectation, HOST will be 0.0.0.0 when ENV=prod is set.

Concepts

Single file convenience

Are you tired of mentally merging settings from multiple files and debugging runtime errors due to missing or misconfigured settings?

# Fragile boilerplate code to load settings from multiple files
def load_settings():
    with open("common.toml") as f:
        settings = toml.load(f)
    if os.environ.get("ENV") == "staging":
        with open("dev.toml") as f:
            settings.update(toml.load(f))
    elif os.environ.get("ENV") == "production":
        with open("prod.toml") as f:
            settings.update(toml.load(f))
    resolve_environment_variables(settings)
    return settings

With a single source of truth, i.e., settingspec.toml, everything is declarative. Everything is visible. No surprise value overrides, or missed overrides.

Strict validation of per profiles settings

[spec]                               # Optional: Declare the specification here
profile.key = "SETTINGSPEC_PROFILE"  # Default: Environment variable name to switch between profiles
profile.options = [                  # Optional: Enables strict checking of per-profile declarations
  "dev",
  "stage",
  "prod",
]
profile.default = "dev"  # Optional: Default profile when the switch is not set

[settings]                   # Declare the settings here with syntax: `<key>._.<profile>.<directive> = <value>`
key1._.default.val = "val1"  # Default value for all profiles
key1._.prod.val = "prod1"    # Override the default value when SETTINGSPEC_PROFILE=prod

key2._ = {  # Strictly define different values for different profiles
  dev.val = "dev2",
  stage.val = "stage2",
  prod.val = "prod2",
}

Overrides are intentional and kept in plain sight (see key1).

By not defining a default value, you can ensure that you never miss adding a value for a specific profile (e.g. this file will refuse to load if key2._.prod declaration is missing).

Language independent

The command-line tool settingspec can export the final settings into well-known formats such as toml, json, yaml, tf, etc., or hard-coded modules such as .py, .js, .ts, etc., write them to disk, print, or pipe them via stdin or even export as environment variables, allowing you to use a single settingspec.toml without worrying about the target language.

[spec]
export.file = true         # Default: Export all settings into settings.toml
# export.file = {          # Optional: Fine-grained control over what to export
#   "settings.py" = true,  # Get auto-completion of settings in your editor/IDE for free
#   "settings.yaml" = {    # Select which keys to export
#     key1 = true,
#     group1 = true,
#     group2.subgroup = true,
#     group3.subgroup.key1 = false,
#     "#tag1" = true,
#   },
# }
export.skip_gitignore = false  # Default: Auto-append exported files to .gitignore if inside a git repo
export.mode = 0x600     # Default: File permission mode for the exported files
export.keep = false     # Default: Cleanup the exported files after the program exits
export.stdout = "toml"  # Optional: Print in toml format
export.env = "PREFIX_"  # Optional: Export as environment variables prefixed with this string
export.stdin = "toml"   # Optional: Pass settings to program stdin in specified format

[settings]
key1._.default.val = "val1"
group1.key1._.default.val = "group1val1"
group2.subgroup.key1._.default.val = "group2val1"
group3.subgroup.key1._.default.val = "group3val1"
group3.subgroup.key1._.tags = ["tag1", "tag2"]  # Syntax: `<key>._.tags = [...]`

Now export the final settings as declared.

settingspec export

Or keep watching to export when settingspec.toml changes.

settingspec watch

Or directly run the program with exported settings.

settingspec run -- [your program]...

If no export option is specified, the default behavior is to export all settings into settings.toml.

Note

If the target programming language supports null values, you can declare it as key._.default.null = true in the settings section.

Easy secrets

Unlike regular settings, secrets aren’t supposed to be easily visible. So, they are best declared inside hidden/encrypted files and passed via environment variables or via stdin.

However, with SettingSpec, we can ensure that they are actually set and also include them in the final exported settings.

SecretSpec

You can pair up SettingSpec with a declarative secrets manager such as SecretSpec.

secretspec.toml

[project]
name = "my-app"
revision = "1.0"

[profiles.default]
SECRET1 = { description = "Secret One", required = true }
SECRET2 = { description = "Secret Two", required = false }

settingspec.toml

[spec]
profile.options = ["dev", "stage", "prod"]

[settings]
secret1._.default.env = "SECRET1"  # Load value from $SECRET1

# Override the default value from environment variables
secret2._.default.val = "defaultvalue"  # Default value if $SECRET2 is not set
secret2._.default.env = "SECRET2"       # Load value from $SECRET2 if set
secret2._.prod.env = "PRODSECRET"       # Load value from $PRODSECRET if SETTINGSPEC_PROFILE=prod
secret2._.prod.val = "prodvalue"        # Fallback for prod only, else fail if $PRODSECRET is not set

With these set up, run:

secretspec run -- settingspec run -- [your program]...

Dotenv

Or keep things simple with Dotenv.

[spec]
profile.options = ["dev", "stage", "prod"]

envfile.default = ".env"    # Default environment file
envfile.stage = ".env.age"  # Also supports age-encrypted env files
envfile.prod = "-"          # Read from stdin, encrypted or not

# decryption.key.env = "SETTINGSPEC_DECRYPTION_KEY"  # Default
# decryption.key.path = "~/.ssh/"                    # Default

[settings]
secret1._.default.env = "SECRET1"     # Load value from $SECRET1

Cottage or agebox

Too simple? Use Cottage or agebox.

[spec]
envfile = ".env.cott.age"
# envfile = ".env.agebox"

decryption.key.path = ["~/.config/cottage/identity", "~/.ssh"]
decryption.key.env = "COTTAGE_IDENTITY"
# decryption.key.env = "AGEBOX_PRIVATE_KEYS"

[settings]
secret._.default.env = "SECRET"

Learn more

Check out the settingspec.toml example file and full specification for more details.

SettingSpec Specification

Version: 0.10
Status: Released
Authors: Arijit Basu and SettingSpec Contributors
Repository: https://github.com/sayanarijit/settingspec


1. Overview

1.1 What SettingSpec does

Software development often requires writing different configuration for development, staging, testing, production etc.

A common approach is to keep separate files such as common.toml, dev.toml, and prod.toml. This creates several problems:

  1. Hard to understand: Developers must combine several files mentally and work out which value wins.
  2. Configuration drift: A setting may be missing in production and the problem may not be noticed until deployment or runtime.
  3. Language-specific configuration: A configuration written for one language may need to be duplicated or converted for another.
  4. Secrets are awkward to manage: Secrets may accidentally be committed to source control or kept separate from the configuration rules or may become the configuration themselves.

SettingSpec uses one configuration file, settingspec.toml, as the source of truth.

It provides:

  • Named profiles such as dev, stage, and prod.
  • Strict validation of profiles and required settings.
  • Values from environment variables.
  • Export to several file formats and programming languages.
  • Support for environment files, including age-encrypted files.
  • Temporary generated configuration files when running a command.

1.2 Required words

The words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL have the meanings defined by RFC 2119.

1.3 Terms

  • Configuration file: The main TOML file: settingspec.toml.
  • Generated configuration file: A file created by SettingSpec during export, run or watch.
  • Project root: The directory containing settingspec.toml.
  • Profile: A named environment or operating mode, such as dev, stage, or prod.
  • Default profile: The default profile. It provides values that apply to all profiles unless a profile overrides them.
  • Setting key: A setting name that can contain dot-separated parts, such as database.host.
  • Separator: The reserved _ component that separates the setting key from the rest of a declaration.
  • Profile declaration: A declaration of the form {key}._.{profile}.{directive}.
  • Directive: The final part of a profile declaration. Supported directives are val, env, and null.
  • Tags declaration: A declaration of the form {key}._.tags. It attaches tags to a setting regardless of profile.
  • Active profile: The profile selected for the current run.
  • Export target: A file or standard output where resolved settings are written.

2. Configuration File

2.1 File name and location

A SettingSpec configuration:

  1. MUST be valid TOML 1.0.
  2. Is stored as settingspec.toml in the project root.
  3. If it is not found in the current directory, SettingSpec searches parent directories until it finds the file. That directory becomes the project root.

The file has two top-level sections:

  • [spec] — optional. Controls profiles, environment files, decryption keys, and exports.
  • [settings] — required. Defines the actual settings and their values.

2.2 Setting names

SettingSpec uses dot-separated keys to represent settings, profiles, and directives. The reserved _ component separates the setting key from the rest of the declaration.

[settings]
key1._.default.val = "val1"
key1._.tags = ["tag1"]

The general forms are:

{key}._.{profile}.{directive} = {value}
{key}._.tags = [{tag}, ...]

3. The [spec] Section

The [spec] section controls:

  • Which profile is active.
  • Which environment files are loaded.
  • How encrypted environment files are decrypted.
  • Where resolved settings are exported.

Example:

[spec]
profile.key = "SETTINGSPEC_PROFILE"
profile.options = ["dev", "stage", "prod"]
profile.default = "dev"

envfile.default = ".env"
envfile.stage = ".env.age"
envfile.prod = "-"

decryption.key.env = "SETTINGSPEC_DECRYPTION_KEY"
decryption.key.path = "~/.ssh/"

[spec.export]
mode = 0x600
keep = false
resolve = false
stdout = "toml"

[spec.export.file]
"settings.toml" = true
"settings.json".group1 = true

3.1 Profiles

The profile settings control how SettingSpec chooses and validates the active profile.

SettingTypeDefaultMeaning
profile.keyStringSETTINGSPEC_PROFILEEnvironment variable used to select the active profile.
profile.optionsArray of stringsEmptyList of allowed profile names.
profile.defaultStringUnsetProfile to use when profile.key is not set or is empty.

Profile validation

When profile.options is set:

  1. The active profile MUST be in the list.
  2. Every profile used in [settings] MUST be in the list or be default.
  3. default MUST NOT be included in profile.options.
  4. Every setting MUST either have a default profile declaration or have a declaration for every profile in profile.options. A tags declaration alone does not count as a profile declaration.
  5. Profile names MUST NOT be default or tags.

If any of these rules fail, configuration loading MUST stop with an error.

3.2 Environment files

Dotenv-style files are loaded before settings are resolved. Their variables are available to env directives and to the child command.

[spec]
envfile.default = ".env"
envfile.stage = ".env.age"
envfile.prod = "-"
  • envfile.<profile> is used for that profile; otherwise envfile.default is used. Files are never merged.
  • - reads dotenv content from standard input.
  • A declared file that is missing MUST cause an error, with no fallback to envfile.default.
  • Age-encrypted files, including stdin, are decrypted before being read.

Shorthand

envfile = ".env" is equivalent to envfile.default = ".env". It cannot be combined with any envfile.<profile> or envfile.default declaration; that is invalid TOML and MUST cause an error.

3.2.1 Age-encrypted environment files

A file is treated as age-encrypted when its name ends in .age or its contents begin with -----BEGIN AGE ENCRYPTED FILE----- (or use the age binary format).

[spec]
decryption.key.env = "SETTINGSPEC_DECRYPTION_KEY"
decryption.key.path = "~/.ssh/"
SettingTypeDefaultMeaning
decryption.key.envStringSETTINGSPEC_DECRYPTION_KEYEnvironment variable holding an age identity or :-separated paths.
decryption.key.pathString or arrayUnsetFallback files or directories containing age identities.

Paths may be files or directories; directories are searched recursively. Passphrase-protected identities are not supported.

Identities are tried in this order, and the first one that decrypts the file is used:

  1. The value of decryption.key.env: either one literal identity (AGE-SECRET-KEY-1...) or paths such as ~/.age/keys:/etc/settingspec/keys.
  2. The paths in decryption.key.path, if the variable is empty or missing.

If none succeeds, SettingSpec MUST stop with an error naming the affected profile and file.

3.3 Export settings

The spec.export section controls how resolved settings are written.

export.mode

  • Type: Integer containing POSIX file permissions.
  • Default: 0x600 (0600).
  • All files created by settingspec export or settingspec run MUST use this mode.

The default allows only the file owner to read and write the file.

export.keep

  • Type: Boolean.
  • Default: false.

When false, generated files are deleted after settingspec run finishes.

When true, generated files remain on disk.

export.resolve

  • Type: Boolean.
  • Default: false.

Controls whether settings that get their value from an environment variable (the env directive) are written to export files as resolved values or as references to the environment variable.

[spec.export]
resolve = true

When true, exported files contain the resolved values of environment variables.

When false, exported files MUST NOT contain the resolved value of a setting that was selected through an env directive. Instead:

  • Static data formats (TOML, JSON, YAML) contain the environment variable name only, as a plain string value.
  • .env files contain a shell reference to the variable, such as DB_PASSWORD=${DB_PASSWORD}.
  • Programming language formats contain the language’s equivalent of reading the environment variable, such as os.environ["DB_PASSWORD"] in Python.
  • Elm and Terraform cannot read environment variables. Exporting a setting that comes from an env directive to these formats with resolve = false MUST cause an error, so that a resolved value is never written silently. Settings that only use val or null export normally.

In statically typed languages, a reference is typed as part of the setting’s type (see section 4.2.1). For a setting whose type is only a string, reading an unset variable fails at runtime using the language’s usual mechanism, or returns an empty string where there is none (Go). If the type includes null, an unset variable is null. Languages that read the environment at runtime evaluate the reference when the exported file is loaded, or in a function for Rust, Zig, and Haskell.

The examples below are for a setting whose type is only a string (“not nullable”) and for a string-or-null setting (“nullable”):

FormatStringString or null
.json, .yaml, .toml"DB_PASSWORD""DB_PASSWORD"
.env${DB_PASSWORD}${DB_PASSWORD}
.pyos.environ["DB_PASSWORD"]os.environ["DB_PASSWORD"]
.js, .mjsprocess.env.DB_PASSWORDprocess.env.DB_PASSWORD
.tsprocess.env.DB_PASSWORD as string(process.env.DB_PASSWORD ?? null)
.rbENV["DB_PASSWORD"]ENV["DB_PASSWORD"]
.luaos.getenv("DB_PASSWORD")os.getenv("DB_PASSWORD")
.rsfn x() -> String (std::env::var(..).expect(..))fn x() -> Option<String> (std::env::var(..).ok())
.goos.Getenv(..)*string from os.LookupEnv(..), nil when unset
.zigfn x() []const u8 (std.posix.getenv(..).?)fn x() ?[]const u8 (std.posix.getenv(..))
.c, .hconst char* (*x)(void) (getenv(..))const char* (*x)(void) (getenv(..), NULL when unset)
.cpp, .hpp, .cc, .cxxstd::string (throws when unset)std::optional<std::string>
.javaString (Objects.requireNonNull(System.getenv(..)))java.util.Optional<String>
.scalalazy val x: String = sys.env(..)lazy val x: Option[String] = sys.env.get(..)
.hsIO String (getEnv ..)IO (Maybe String) (lookupEnv ..)

Settings whose value comes from val or null are always exported as-is, regardless of this option.

When a setting has an env directive and a val or null fallback (see section 4.2), the fallback is preserved only in dynamically typed languages, since the type of a statically typed setting already includes the kind of the fallback, where the exported file then behaves like the resolution rules in section 5.2:

FormatOutput when resolve = false and fallback is "fallback"
.pyos.environ.get("DB_PASSWORD", "fallback")
.js, .mjs(process.env.DB_PASSWORD ?? "fallback")
.rbENV.fetch("DB_PASSWORD", "fallback")
.lua(os.getenv("DB_PASSWORD") or "fallback")

Static data formats, .env files, TypeScript and the other statically typed languages cannot express the fallback without changing the type. With resolve = false, exporting a setting that has an env directive with a val or null fallback (in the active profile, or in default when the active profile has no declaration of its own) to such a format MUST cause an error, so that the fallback is never silently dropped and the exported file never behaves differently from the resolution rules in section 5.2. Only the formats in the table above (.py, .js, .mjs, .rb, .lua) support env fallbacks. Settings with an env directive and no fallback export normally to every format that supports env references.

With resolve = true this restriction does not apply, because the resolved value is written.

When resolve = false, a setting MUST use the env directive consistently across profiles. If any profile declaration of a setting contains an env directive, every other profile MUST also contain an env directive for that setting, so that the exported reference never depends on the active profile. A default declaration with env covers every profile that has no declaration of its own, but a profile that declares only val or null is not covered. Fallbacks are allowed: an env directive MAY be accompanied by a val or null fallback in any profile, and the fallback does not need to be present in every profile. Configuration loading MUST stop with an error when this rule is violated:

# Invalid when resolve = false: `prod` has no env directive
password._.default.env = "DB_PASSWORD"
password._.prod.val = "static"

# Valid: every profile uses env; fallbacks are optional
password._.default.env = "DB_PASSWORD"
password._.prod.env = "PROD_DB_PASSWORD"
password._.prod.val = "fallback"

This rule does not apply when resolve = true.

Likewise, when resolve = false, a setting whose env directive has a fallback MUST NOT be exported to a file whose format cannot express the fallback (see above).

resolve does not otherwise change validation. The settings are still resolved for the active profile as described in section 5.2, so a required environment variable that is missing MUST still cause an error even when resolve = false.

resolve applies to export.file and export.stdout. It does not affect export.env or export.stdin, which always pass resolved values to the child process.

export.stdout

  • Type: Boolean or format name.
  • Default: Disabled.
export.stdout = true

or

export.stdout = "json"

true or "" means TOML. A format name such as json, yaml, toml, or env selects that format.

export.file

Defines the files to generate.

[spec.export.file]
"settings.toml" = true
"settings.yaml" = { key1 = true, group1 = true, "#tag1" = true }

If no export configuration is given, the default is:

[spec.export.file]
"settings.toml" = true

true exports all settings. false disables that target.

Environment-style files such as .env and *.env use env format.

An unknown output format MUST cause an error.

export.env

Exports resolved settings as environment variables for the child process started by settingspec run.

export.env = true

Converts:

database.host

to:

DATABASE_HOST

A prefix can also be supplied:

export.env = "PREFIX_"

This produces:

PREFIX_DATABASE_HOST

false or omission disables this behavior.

Settings with null values are not exported as environment variables.

export.stdin

Passes resolved settings to the child process through standard input.

export.stdin = true

uses TOML. A format name can be used instead:

export.stdin = "json"

false or omission disables this behavior.

export.skip_gitignore

By default, when running inside a Git repository, SettingSpec adds generated export files to .gitignore if they are not already listed.

spec.export.skip_gitignore = true

disables this automatic behavior.


4. The [settings] Section

The [settings] section defines setting values, environment variables, tags, and profile-specific overrides.

4.1 Declaration format

Each setting is defined by one or more declarations. There are two kinds.

A profile declaration provides a value for a profile:

{key}._.{profile}.{directive} = {value}

A tags declaration attaches tags to a setting:

{key}._.tags = [{tag}, ...]

For example:

app.name._.default.val = "MyApp"
app.port._.dev.val = 8080
app.port._.prod.val = 80
app.port._.tags = ["network"]

The parts are:

  • {key} — the setting name, such as database.host. It can contain dot-separated parts.
  • _ — the reserved separator between the key and the rest of the declaration.
  • {profile} — a profile from profile.options, or default.
  • {directive} — val, env, or null.
  • {value} — the value for that directive.

Parsing declarations

SettingSpec reads a dotted declaration by splitting it at the _ component:

  1. Everything before the _ component is the setting key. It MUST NOT be empty.
  2. Everything after the _ component MUST be one of:
    • the single component tags, which makes it a tags declaration, or
    • exactly two components, {profile}.{directive}, which makes it a profile declaration.
  3. A declaration with no _ component, more than one _ component, or any other shape after the _ component is malformed.

Because the separator is explicit, setting keys MAY contain parts named val, env, null, or tags. For example, group1.env._.default.val declares the setting group1.env.

4.1.1 Reserved names

The name _ is reserved as the separator.

  • It MUST NOT be used as any part of a setting key.

The names default and tags are reserved in the profile position.

  • default is the default profile. It MUST NOT be listed in profile.options.
  • tags marks a tags declaration. It MUST NOT be used as a profile name.

For example, these are invalid:

_.default.val = "x"                 # empty key
key1.default.val = "x"              # missing `_` separator
key1._.default._.val = "y"          # more than one `_` component
key1._.tags.val = "z"               # `tags` used as a profile
key1._.default = "w"                # missing directive

SettingSpec MUST reject these during configuration validation and report the offending declaration.

4.2 Directives

val

Provides a fixed value.

Supported values include:

  • String
  • Integer
  • Float
  • Boolean
  • RFC 3339 datetime
  • Array
  • Inline table

Example:

app.name._.default.val = "MyApp"
app.port._.dev.val = 8080
app.port._.prod.val = 80

env

Gets the value from an environment variable.

database.password._.default.env = "DB_PASSWORD"

SettingSpec reads DB_PASSWORD when the setting is resolved. This includes variables loaded from spec.envfile.

Using val or null with env

A setting can provide an environment variable plus a fallback value:

secret2._.default.val = "defaultvalue"
secret2._.default.env = "SECRET2"
secret2._.prod.env = "PRODSECRET"
secret2._.prod.val = "prodvalue"

The rules are:

  1. If the environment variable exists, use it.
  2. If it does not exist, use the val or null fallback declared in the same profile as the env directive.
  3. If the profile has an env directive but no val or null of its own, and the environment variable is missing, resolution MUST fail. A fallback declared in another profile, including default, is never used.

In the example above, prod falls back on "prodvalue", never on "defaultvalue". Without secret2._.prod.val, resolving secret2 for prod would fail when PRODSECRET is unset.

null

Marks a setting as unset for a profile.

debug_banner._.default.val = "My App BETA 0.0.1"
debug_banner._.prod.null = true

When exported:

  • JSON, YAML, Python, Lua, Rust, Go, Zig, C, C++, Java, Elm, Ruby, Scala, Haskell, and Terraform use their native null value.
  • TOML and .env omit the setting.
  • null can only be true.
  • null cannot be used together with val for the same profile.

4.2.1 Setting types

The type of a setting is the union of:

  • the kinds of its val values across all profiles, including default (string, integer, float, boolean, array, table; datetimes are strings),
  • string, if any profile has env,
  • null, if any profile has null (including as an env fallback).

A setting that is only ever null is typed as string or null.

Statically typed formats (TypeScript, Rust, Go, Zig, C, C++, Java, Elm, Scala, Haskell) MUST use this type in every profile, so exported types never depend on the active profile. An environment value parsed as a kind outside the type becomes a string (see section 5.2).

Python annotates each setting with the type (port: str | int | None = 5432) and keeps the native value. Other formats (TOML, JSON, YAML, .env, JavaScript, Ruby, Lua, Terraform) keep native values without annotations.

A single kind without null is just that kind. Otherwise:

FormatOne kind plus nullSeveral kinds (with or without null)
.tsnumber | nullstring | number | null
.rsOption<i64>generated enum PortValue { Str(String), Int(i64), Null }
.go*intany
.zig?i64generated union(enum)
.cpp, .hpp, .cc, .cxxstd::optional<long long>std::variant<std::nullptr_t, std::string, long long>
.javajava.util.Optional<Long>Object
.scalaOption[Long]Any (Option[Any] with null)
.hsMaybe Intgenerated data type
.elmMaybe Intgenerated custom type
.c, .hconst char* (strings only)not supported

Arrays and tables are supported in a union only by TypeScript, and by Go, Java, and Scala for arrays. Exporting a setting whose type is not supported by the format MUST cause an error.

4.3 Tags

Tags add metadata to a setting. They are declared after the key, not per profile, so they apply to the setting in every profile.

api_key._.default.env = "API_KEY"
api_key._.tags = ["sensitive", "auth"]

The rules are:

  1. The value MUST be an array of strings.
  2. A setting MAY have at most one tags declaration.
  3. A tags declaration does not define a value. A setting that has only a tags declaration and no profile declarations is treated as having no declaration for resolution purposes (see section 5.2).

Tags are not exported as setting values. They can be used when selecting settings for an export, for example with #auth.


5. Resolving and Validating Settings

5.1 Choosing the active profile

SettingSpec chooses the active profile in this order:

  1. The environment variable named by spec.profile.key (default: SETTINGSPEC_PROFILE).
  2. spec.profile.default.
  3. If neither is set:
    • If profile.options exists, SettingSpec MUST stop with an error because no profile was selected.
    • Otherwise, SettingSpec MAY continue using default-only resolution.

5.2 Choosing a setting value

For a setting K and active profile P, SettingSpec follows this flow:

flowchart TD
    Start["Resolve key K for profile P"] --> HasProfileDecl{"Has a declaration for profile P?"}

    HasProfileDecl -- Yes --> CheckProfileEnv{"Does profile P have env?"}
    CheckProfileEnv -- Yes --> CheckEnvExists{"Is the environment variable set?"}
    CheckEnvExists -- Yes --> ReturnEnv["Use the environment variable"]
    CheckEnvExists -- No --> CheckEnvFallback{"Does profile P have val/null?"}
    CheckEnvFallback -- Yes --> ReturnProfileVal
    CheckEnvFallback -- No --> EnvError["Error: required environment variable is missing"]

    CheckProfileEnv -- No --> CheckProfileVal{"Does profile P have val/null?"}
    CheckProfileVal -- Yes --> ReturnProfileVal["Use profile P val/null"]
    CheckProfileVal -- No --> CheckDefault["Check default"]

    HasProfileDecl -- No --> CheckDefault{"Has a default declaration?"}
    CheckDefault -- Yes --> CheckDefaultEnv{"Does default have env?"}
    CheckDefaultEnv -- Yes --> CheckDefaultEnvExists{"Is the environment variable set?"}
    CheckDefaultEnvExists -- Yes --> ReturnDefaultEnv["Use the default environment variable"]
    CheckDefaultEnvExists -- No --> CheckDefaultVal{"Does default have val/null?"}

    CheckDefaultEnv -- No --> CheckDefaultVal
    CheckDefaultVal -- Yes --> ReturnDefaultVal["Use default val/null"]
    CheckDefaultVal -- No --> ResolutionError["Error: required value is missing"]

    CheckDefault -- No --> StrictCheck{"Is spec.profile.options defined?"}
    StrictCheck -- Yes --> MissingCoverError["Error: key is missing a profile"]
    StrictCheck -- No --> KeyOmitted["Omit the key"]

For the same rules in numbered form:

  1. If K has a profile declaration for P:
    • If it has an env directive and the variable exists, use that value.
    • If it has an env directive and the variable is missing, use its own val or null value. If it has none, resolution MUST fail; default is not consulted.
    • If it has no env directive, use its val or null value.
  2. If there is no declaration for P, check default:
    • If the default env variable exists, use it.
    • Otherwise, use the default val or null value. If it has none, resolution MUST fail.
  3. If no default exists:
    • With profile.options, this is a profile-coverage error.
    • Without profile.options, the key is omitted.

Tags declarations are not profile declarations and play no part in this flow.

The flow above assumes that each declaration has already been parsed into its key, profile, and directive. Malformed declarations and reserved names must be rejected during validation.

Environment files and their decryption are completed before this process starts, so their variables are available during resolution.

5.3 Validation before running

SettingSpec MUST validate the configuration before exporting or executing anything.

It checks that:

  1. Every profile declared in settings belongs to the options declared in spec.
  2. Every setting has complete profile coverage when strict profile validation is enabled.
  3. Every declaration is well-formed and uses a valid directive (val, env, or null) or is a valid tags declaration.
  4. No setting key contains the reserved _ component, and no profile name is default or tags in profile.options.
  5. Environment values for the active profile are present if required.
  6. When spec.export.resolve = false, every setting that has an env directive in any profile has an env directive in all other profiles (fallbacks allowed), as described in section 3.3.
  7. Every configured export (export.file, export.stdout, export.stdin) can be generated for the active profile, including its format, the setting types it must express, and the env fallback restriction in section 3.3. The output is discarded.

6. Selecting Settings for Export

The spec.export.file section can export all settings or only selected settings.

6.1 Simple filters

[spec.export.file]
"settings.toml" = true  # same as spec.export.file = true
  • true — export all resolved settings.
  • false — do not create that file.

6.2 Detailed filters

A file can select specific keys, groups, or tags:

[spec.export.file]
"settings.yaml" = {
  key1 = true,
  group1 = true,
  group2.subgroup = true,
  group3.subgroup.key1 = false,
  "#tag1" = true
}

The same can be written as a TOML table:

[spec.export.file."settings.yaml"]
key1 = true
group1 = true
group2.subgroup = true
group3.subgroup.key1 = false
"#tag1" = true

6.2.1 TOML rules

TOML does not allow a name to be both a scalar and a table.

This is invalid:

# Invalid TOML
group2 = true
group2.subgroup = false

TOML also does not allow duplicate keys in the same scope.

SettingSpec therefore applies filter rules hierarchically instead of allowing conflicting definitions of the same TOML path.

6.2.2 Filter rules

Exclusions always win

A false rule always overrides a matching true rule.

If a setting matches an exclusion, it is not exported.

Include a group

group1 = true

Includes everything under group1, including nested settings.

Include a subgroup

group1.subgroup1 = true

Includes everything under that subgroup.

Include one exact key

group2.subgroup2.key = true

Includes only that key. Sibling keys are not included unless selected separately.

Exclude part of a group

group3.subgroup = false

This means: include the parent group, except for group3.subgroup.

Likewise:

group2.subgroup2.key = false

excludes that key from all its parent namespaces.

Mix inclusion and exclusion

group2.subgroup2.key1 = true
group2.subgroup2.key2 = false

Only key1 is included. key2 is excluded, and unmentioned sibling keys are not included.

Select by tag

"#tag1" = true

includes settings with the tag1 tag.

"#tag2" = false

excludes settings with the tag2 tag, even if their parent group is included.

Explicit and exclusion-only filters

If a filter contains an inclusion rule, only matching settings are candidates for export.

If a filter contains only top-level exclusions, all settings are candidates except those excluded.

6.3 Filter examples

PatternExampleResult
Group inclusiongroup1 = trueInclude everything under group1.
Subgroup inclusiongroup1.subgroup1 = trueInclude everything under that subgroup.
Exact keygroup2.subgroup2.key = trueInclude only that key.
Subgroup exclusiongroup3.subgroup = falseInclude group3 except that subgroup.
Key exclusiongroup2.subgroup2.key = falseInclude the parent namespace except that key.
Mixed ruleskey1 = true, key2 = falseInclude key1 only.
Tag exclusiongroup1 = true, "#sensitive" = falseInclude group1 except sensitive settings.

6.4 Export formats

SettingSpec determines the output format from the destination file extension.

ExtensionFormatNull handling
.tomlTOML 1.0Null settings are omitted.
.jsonJSONNull becomes null.
.yaml, .ymlYAMLNull becomes null or ~.
.pyPython moduleNull becomes None.
.js, .mjsJavaScript moduleNull becomes null.
.tsTypeScript moduleNull becomes null.
.luaLua tableNull becomes nil.
.env, .env.*, env.*, *.envShell environmentNull settings are omitted.
.rsRust moduleNull becomes None.
.goGo packageNull becomes nil.
.zigZig moduleNull becomes null.
.c, .hC header/sourceNull becomes NULL.
.cpp, .hpp, .cc, .cxxC++ header/sourceNull becomes nullptr.
.javaJava classNull becomes null.
.elmElm moduleNull becomes Nothing.
.rbRuby moduleNull becomes nil.
.scalaScala objectNull becomes None.
.hsHaskell moduleNull becomes Nothing.
.tf, .tfvarsTerraform HCLNull becomes null.

7. Command-Line Interface

SettingSpec provides a command-line program named settingspec.

7.1 Common options

All commands support:

-h, --help       Show help.
-V, --version    Show the version.

7.2 init

Creates a starter settingspec.toml in the current directory.

settingspec init [OPTIONS]

7.3 check

Checks the configuration without creating files.

settingspec check [OPTIONS]

It checks:

  • TOML syntax.
  • Malformed declarations, the reserved _ separator in setting keys, and reserved profile names (default, tags).
  • Profile completeness.
  • Consistent use of env directives across profiles when spec.export.resolve = false.
  • Required environment variables for active profile.
  • Decryption identities for encrypted environment files used by the active profile.

Exit codes:

  • 0 — validation passed.
  • Non-zero — validation failed.

7.4 export

Generates the configured export files and/or writes resolved settings to standard output.

settingspec export [OPTIONS]

7.5 run

Runs another command with the resolved configuration available to it.

settingspec run [OPTIONS] -- <COMMAND> [ARGS...]

What happens during run

  1. SettingSpec resolves the active profile and settings.
  2. It creates the configured export files using spec.export.mode (default 0x600).
  3. It starts the requested command.
  4. If configured, environment variables are provided to the child process.
  5. If configured, resolved settings are sent to the child’s standard input.
  6. Signals such as SIGINT and SIGTERM are forwarded to the child process.
  7. When the child finishes:
    • With export.keep = false (default), generated files are deleted.
    • With export.keep = true, generated files remain.
  8. SettingSpec exits with the same status code as the child command.

7.6 watch

Runs SettingSpec as a long-running service and regenerates the configured export files whenever settingspec.toml changes.

Upon exit, files are cleaned up as per declared spec.export.keep behavior.

settingspec watch [OPTIONS]

If settingspec.toml already exists, init does nothing and does not overwrite it.

7.7 clean

Deletes the export files defined in spec.export.file. Keeps the gitignore entries.

settingspec clean [OPTIONS]

8. Security

8.1 Restrict generated files

Generated configuration files use spec.export.mode, which defaults to 0600.

On POSIX systems, this prevents other unprivileged users on the same machine from reading the files.

8.2 Remove temporary secrets

By default, settingspec run deletes generated export files when the child process finishes, including when the child fails or is terminated by a signal.

8.3 Keep generated files out of Git

Generated files can contain secrets. They SHOULD be excluded from source control.

Keeping spec.export.resolve = false (the default) avoids writing environment-sourced secrets into generated files at all.

SettingSpec automatically adds generated export targets to .gitignore adjacent to project root when running inside a Git repository, unless:

spec.export.skip_gitignore = true

is set.

8.4 Prefer stdin or environment variables for secrets

When using a secret manager such as SecretSpec, 1Password CLI, or Vault, secrets SHOULD be passed through environment variables or standard input instead of being written to persistent disk.

For example:

envfile.prod = "-"

8.5 Encrypted environment files

Age-encrypted environment files may be committed to source control because their contents cannot be read without a valid decryption identity.

Decryption identities supplied through decryption.key.env, including colon-separated multiple paths, MUST NOT be committed to source control or logged in plain text.

8.6 Restrict AI agents

If the settings contain sensitive keys, AI agents MUST NOT be given access to the settingspec command or the sensitive secret sources in any way.

You SHOULD prefer workflows that do not require exporting settings to disk or exposing them to the AI.

In the presence of encrypted env files, the decryption key SHOULD be kept out of the AI’s reach.