Configuration & Auth

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Python 3.12 (tomllib from the standard library), platformdirs, keyring and python-dotenv, the XDG Base Directory Specification, clig.dev and the docsctl running example of this sub-section, as checked on 2026-09-30. The docsctl config keys, file locations and variables are illustrative; the fictional tool exists only on these pages. Every credential in this page is a placeholder such as $DOCSCTL_TOKEN; never paste a real token into a command line, a file in a repository or a prompt.

A tool needs two kinds of input that are not the command’s arguments: configuration (how to behave: output format, timeouts, which server) and credentials (who is calling). The design questions are the same for both: where can each value come from, which source wins when there are several, and how does a caller that has no terminal and no browser supply them. Humans set them once and forget; scripts and agents need them to be explicit, inspectable and overridable per run.

Configuration precedence

When the same setting can come from several places, the order in which they are consulted must be fixed, documented and the same for every setting. clig.dev’s order, from highest to lowest priority, is the one used across this sub-section:

  1. Flags: this invocation, written on the command line.

  2. Environment variables: this shell, session or job.

  3. Project configuration: the directory the tool is run in.

  4. User configuration: this person, on this machine.

  5. System configuration: everyone on the machine.

The closer a source is to the individual invocation, the higher it ranks. A flag beats a variable because the caller typed it just now; a variable beats a file because the file was written earlier for a broader purpose.

Table 1. The docsctl configuration layers, highest priority first
Layer Location Typical content

Flags

--format json, --limit 5, --config PATH, --profile NAME

The value this command needs.

Environment

DOCSCTL_FORMAT, DOCSCTL_LIMIT, DOCSCTL_TIMEOUT, DOCSCTL_PROFILE, DOCSCTL_CONFIG, DOCSCTL_BASE_URL, DOCSCTL_LOG_FORMAT, DOCSCTL_NO_INPUT=1

Settings for a CI job or an agent’s session.

Project

./.docsctl.toml, found by walking up from the current directory

Defaults shared by everyone who works on this repository (component, format).

User

$XDG_CONFIG_HOME/docsctl/config.toml; ~/.config/docsctl/config.toml when unset (macOS and Windows: see below)

Personal defaults: format, limit, timeout, profiles.

System

/etc/docsctl/config.toml

Site-wide defaults set by an administrator.

The keys of the TOML files mirror the flag names without the leading hyphens: format, limit, timeout, color, base_url, profile, log_format, plus one [profiles.NAME] table per profile with its own base_url and token_file. Honour the environment variables that other tools already understand (NO_COLOR, TERM=dumb and CI) before you invent new ones, and give every tool-specific variable a prefix (DOCSCTL_) so it cannot collide.

docsctl.toml at the project level
# .docsctl.toml -- committed with the repository; never put credentials in it
format = "json"
limit = 20

[profiles.staging]
base_url = "https://staging.example.invalid/docs"
Merging the layers with the required precedence (Python)
import os
import tomllib
from pathlib import Path

from platformdirs import user_config_dir

DEFAULTS = {"format": "text", "limit": 10, "timeout": 30, "log_format": "text"}
ENV = {
    "format": ("DOCSCTL_FORMAT", str),
    "limit": ("DOCSCTL_LIMIT", int),
    "timeout": ("DOCSCTL_TIMEOUT", int),
    "log_format": ("DOCSCTL_LOG_FORMAT", str),
    "base_url": ("DOCSCTL_BASE_URL", str),
    "profile": ("DOCSCTL_PROFILE", str),
}


def read_toml(path: Path) -> dict:
    try:
        with path.open("rb") as handle:
            return tomllib.load(handle)
    except FileNotFoundError:
        return {}


def find_project_config(start: Path) -> Path | None:
    for directory in (start, *start.parents):
        candidate = directory / ".docsctl.toml"
        if candidate.is_file():
            return candidate
    return None


def from_environment() -> dict:
    return {key: cast(os.environ[name]) for key, (name, cast) in ENV.items() if os.environ.get(name)}


def load_config(flags: dict, cwd: Path) -> dict:
    """Lowest priority first, so later layers overwrite earlier ones."""
    user_dir = Path(user_config_dir("docsctl", appauthor=False, roaming=True))
    project = find_project_config(cwd)
    layers = [
        DEFAULTS,
        read_toml(Path("/etc/docsctl/config.toml")),                 # system
        read_toml(user_dir / "config.toml"),                         # user
        read_toml(project) if project else {},                       # project
        from_environment(),                                          # environment
        {k: v for k, v in flags.items() if v is not None},           # flags that were actually passed
    ]
    merged: dict = {}
    for layer in layers:
        merged.update(layer)
    return merged

Two habits make this layered scheme debuggable, which matters most when an agent is puzzled by behaviour it did not configure. With --verbose, log to stderr which files were read and which layer supplied each effective value; and let --config PATH (or DOCSCTL_CONFIG) replace the file search entirely, so a test or a sandbox never depends on the machine’s own files. References: clig.dev — Configuration, Python — tomllib and platformdirs.

XDG base directories and their macOS and Windows equivalents

The XDG Base Directory Specification tells Linux and other Unix programs where to put their files, so home directories are not filled with dotfiles. Each directory has an environment variable and a default:

Variable Default What goes there

XDG_CONFIG_HOME

$HOME/.config

User configuration: docsctl/config.toml.

XDG_DATA_HOME

$HOME/.local/share

User data that should persist.

XDG_STATE_HOME

$HOME/.local/state

State that is not worth backing up: logs, history.

XDG_CACHE_HOME

$HOME/.cache

Disposable cache (downloaded indexes); safe to delete.

XDG_RUNTIME_DIR

none (set by the login session)

Sockets and lock files for the running session.

XDG_CONFIG_DIRS

/etc/xdg

Additional, lower-priority, system-wide configuration directories.

The specification requires the paths in these variables to be absolute and says a relative path is invalid and to be ignored; a tool must not trust a relative XDG_CONFIG_HOME. clig.dev tells tools to follow the spec, and notes that many well-known programs do.

macOS and Windows have their own conventions, and a tool that ships on all three should use each platform’s own place, or state clearly that it uses the XDG layout everywhere:

Purpose Linux (XDG) macOS Windows

Configuration

$XDG_CONFIG_HOME/docsctl/ (~/.config/docsctl/)

~/Library/Application Support/docsctl/

%APPDATA%\docsctl\ (roaming)

Data

$XDG_DATA_HOME/docsctl/

~/Library/Application Support/docsctl/

%LOCALAPPDATA%\docsctl\

Cache

$XDG_CACHE_HOME/docsctl/

~/Library/Caches/docsctl/

%LOCALAPPDATA%\docsctl\Cache\

System configuration

/etc/docsctl/

/Library/Application Support/docsctl/ (or /etc/docsctl/)

%PROGRAMDATA%\docsctl\

Not every popular tool follows the native macOS locations; some use ~/.config on macOS as well because developers expect it. Either choice is defensible if it is documented. In code, do not hand-write these paths: libraries such as platformdirs (Python; user_config_dir, user_cache_dir, user_data_dir, with appauthor=False and roaming=True to get the Windows layout in the table), Typer and Click’s get_app_dir, or the equivalent in your language compute them. Reference: XDG Base Directory Specification and platformdirs.

Project config files and .env

A project config file is a small, version-controlled file that lives next to the code and configures the tool for that project, like Makefile, package.json or docker-compose.yml. clig.dev recommends a command-specific, version-controlled file for this: .docsctl.toml records the component and format that everyone on the repository wants, and it is found by walking up from the current directory, as git finds .git. It has a schema (TOML keys with types), can be reviewed in a pull request, and carries history.

A .env file is a plain list of NAME=value lines that a tool loads into its environment. clig.dev advises reading variables from a local .env where it fits, for values that rarely change while the user works in one directory, and warns against using it as a substitute for a real configuration file, because a .env is usually kept out of version control (so it has no history), holds only strings, is easily disorganised, and often ends up holding credentials that would be safer elsewhere.

  • Load .env below the real environment: a variable already exported by the shell or the CI job wins. That is also the default of python-dotenv: load_dotenv() does not override existing variables unless override=True is passed.

  • Add .env to .gitignore, and read only variables that carry your tool’s prefix (DOCSCTL_*), never the whole file into the process environment.

  • Prefer the TOML file for structured settings and keep .env for a handful of per-directory strings.

Loading .env without overriding the real environment (Python)
from dotenv import find_dotenv, load_dotenv

load_dotenv(find_dotenv(usecwd=True))  # searches upwards from the current directory; existing variables win

Treat both project-level files as untrusted input when the tool may run inside a freshly cloned or downloaded repository, which is exactly where an agent works. A malicious .docsctl.toml or .env that sets base_url to another server would receive the user’s token on the next request. Two defences: do not let a project file set credential-bearing keys (token_file, base_url of an authenticated profile) without an explicit, one-time trust decision by the user, and never send a stored credential to a server that the user did not configure at user or system level. Security returns to this. Reference: python-dotenv.

Token files, keychains and environment variables: the trade-off

A credential has to be stored somewhere between runs and handed to the process when it starts. Each mechanism moves the risk to a different place.

Mechanism Strengths Weaknesses Fit

Environment variable (DOCSCTL_TOKEN)

Nothing on disk; trivial to set from a CI secret store; process-scoped and easy to override per run; no library needed.

Inherited by every child process; can appear in debug logs, crash dumps, docker inspect output and process listings; visible to anything that can read the process environment.

CI jobs and ephemeral agent sandboxes.

Token file (DOCSCTL_TOKEN_FILE, or credentials in the user config directory)

Easy to protect with file permissions (0600); not inherited by children; works over SSH and in containers with a mounted secret; read only when needed.

A secret at rest on disk; a process with file access can read it, including an agent with a file-reading tool; needs a permissions check.

Servers, containers with mounted secrets, machines without a keychain.

OS keychain or credential store (macOS Keychain, Windows Credential Manager, Secret Service on Linux)

Encrypted at rest; unlocked with the user’s login; the best experience for a person on a workstation.

Needs a platform library; often unavailable on headless Linux, in containers and in CI; prompts or fails without a session.

Interactive human use.

Standard input (login --token-stdin)

The value never appears in the command line, shell history or environment; composes with a secret manager’s CLI.

Only moves the secret into one of the storage mechanisms above; one-shot.

The bridge from a secret manager into the tool.

The two schools of thought disagree, and both have a case. clig.dev says plainly not to read secrets from environment variables, because exported variables go to every child process, a shell substitution such as curl -H "Authorization: Bearer $TOKEN" leaks into process state, and docker inspect and systemctl show reveal container and unit environments; it recommends accepting secrets through files or standard input, and never through flag values. Engineering posts on building CLIs for agents, such as Arcjet’s (an engineering blog, not a specification), recommend the opposite for headless use: an environment variable such as ARCJET_TOKEN that takes priority over stored credentials, so that an agent can authenticate without reaching into a keychain or driving a browser flow.

Read together, they are answering different questions. The environment variable optimises for the caller that has no terminal, no keychain and no browser, and that is short-lived and sandboxed; the file or keychain optimises against leakage on a long-lived machine that many processes share. The workable position, which docsctl takes, is to support several sources in a fixed order and let the deployment choose:

  1. DOCSCTL_TOKEN (the environment variable; for CI and sandboxes)

  2. DOCSCTL_TOKEN_FILE (a path to a file, ideally a mounted secret)

  3. The OS credential store

  4. A credentials file in the user config directory, mode 0600

Whatever you choose, some rules hold for all of them: never accept a secret as a flag value, never print it (mask it in --verbose and in error messages), never write it to a project file or a committed config, refuse to read a token file that other users can read, and give the token the fewest permissions and the shortest lifetime the task needs.

Resolving the token in the documented order (Python)
import os
import stat
from pathlib import Path

import keyring
from platformdirs import user_config_dir


def read_token_file(path: Path) -> str | None:
    try:
        mode = path.stat().st_mode
    except FileNotFoundError:
        return None
    if os.name == "posix" and stat.S_IMODE(mode) & 0o077:
        raise PermissionError(f"{path} must not be readable by other users (chmod 600)")
    return path.read_text(encoding="utf-8").strip() or None


def resolve_token(profile: str = "default") -> tuple[str, str] | None:
    """Return (token, source) or None. The source name is safe to log; the token is not."""
    token = os.environ.get("DOCSCTL_TOKEN")
    if token:
        return token.strip(), "env:DOCSCTL_TOKEN"

    file_from_env = os.environ.get("DOCSCTL_TOKEN_FILE")
    if file_from_env and (token := read_token_file(Path(file_from_env))):
        return token, "file:DOCSCTL_TOKEN_FILE"

    try:
        token = keyring.get_password("docsctl", profile)
    except keyring.errors.KeyringError:
        token = None                      # no usable keychain (headless Linux, container)
    if token:
        return token, "keychain"

    config_dir = Path(user_config_dir("docsctl", appauthor=False, roaming=True))
    if token := read_token_file(config_dir / "credentials"):
        return token, "file:credentials"
    return None


def store_token(token: str) -> Path:
    """Write the credentials file with mode 0600 from the moment it is created."""
    config_dir = Path(user_config_dir("docsctl", appauthor=False, roaming=True))
    config_dir.mkdir(parents=True, exist_ok=True)
    path = config_dir / "credentials"
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        handle.write(token + "\n")
    return path

keyring.get_password(service, username) and keyring.set_password(service, username, password) are the library’s public API; on a machine without a usable backend they raise an error subclassing keyring.errors.KeyringError, which is why the code above falls through to the next source instead of failing. When no source yields a token, exit with the authentication code and an actionable hint ("Run docsctl login or set DOCSCTL_TOKEN"), as shown on the page about streams and exit codes. References: clig.dev — Environment variables, keyring, The Twelve-Factor App — Config and Arcjet — Designing a CLI for AI agents.

Headless auth: no browser flow required

A browser-based login (open a URL, sign in, redirect back to a local port) is the friendliest way for a person to authenticate and the worst way for anything else: a CI runner, a container, an SSH session and an agent’s shell tool have no browser to open. The rule is that every credential path a human can use must have a headless equivalent, and that the headless one is the default in non-interactive mode.

  • Accept a token directly. docsctl login --token-stdin reads the token from standard input and stores it; the value never appears in ps, shell history or the tool’s arguments. The same works from a secret manager.

  • Accept it from the environment for a single run. DOCSCTL_TOKEN (or DOCSCTL_TOKEN_FILE) is read on every run and requires no login step at all, which is what makes a CI job or a sandboxed agent self-contained.

  • Offer a device-code flow for people on the far side of a terminal. docsctl login --device-code prints a short code and a URL on stderr and waits while the person signs in on another device; this follows the OAuth 2.0 Device Authorization Grant (RFC 8628) and needs no local browser or listening port.

  • Fail fast, never wait. With no terminal or with --no-input, a command that finds no credentials exits with the authentication code and the hint; it does not open a browser and it does not sit waiting for a redirect that will never arrive.

Headless login from a secret manager or a file, using a placeholder token
# From a file that the platform mounts as a secret
docsctl login --token-stdin < "$DOCSCTL_TOKEN_FILE"

# Or per run, with no stored state; DOCSCTL_TOKEN is set by the CI secret store, not typed by hand
DOCSCTL_TOKEN="$DOCSCTL_TOKEN" docsctl publish --component irurueta-algebra --dry-run --json

The GitHub CLI has the same three doors, which is one reason it is a good reference: gh auth login --with-token reads a token from standard input, and a GH_TOKEN environment variable takes effect without any login (its manual points to GH_TOKEN for use in GitHub Actions). A last consideration for agent runs: a credential handed to a tool inside an agent session can be seen by that session, so give it the least privilege possible, prefer short-lived tokens, and see Security for sandboxing. References: RFC 8628 — OAuth 2.0 Device Authorization Grant and GitHub CLI — gh auth login.