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:
-
Flags: this invocation, written on the command line.
-
Environment variables: this shell, session or job.
-
Project configuration: the directory the tool is run in.
-
User configuration: this person, on this machine.
-
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.
| Layer | Location | Typical content |
|---|---|---|
Flags |
|
The value this command needs. |
Environment |
|
Settings for a CI job or an agent’s session. |
Project |
|
Defaults shared by everyone who works on this repository (component, format). |
User |
|
Personal defaults: format, limit, timeout, profiles. |
System |
|
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 -- committed with the repository; never put credentials in it
format = "json"
limit = 20
[profiles.staging]
base_url = "https://staging.example.invalid/docs"
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 |
|---|---|---|
|
|
User configuration: |
|
|
User data that should persist. |
|
|
State that is not worth backing up: logs, history. |
|
|
Disposable cache (downloaded indexes); safe to delete. |
|
none (set by the login session) |
Sockets and lock files for the running session. |
|
|
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 |
|
|
|
Data |
|
|
|
Cache |
|
|
|
System configuration |
|
|
|
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
.envbelow the real environment: a variable already exported by the shell or the CI job wins. That is also the default ofpython-dotenv:load_dotenv()does not override existing variables unlessoverride=Trueis passed. -
Add
.envto.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
.envfor a handful of per-directory strings.
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 ( |
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, |
CI jobs and ephemeral agent sandboxes. |
Token file ( |
Easy to protect with file permissions ( |
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 ( |
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:
-
DOCSCTL_TOKEN(the environment variable; for CI and sandboxes) -
DOCSCTL_TOKEN_FILE(a path to a file, ideally a mounted secret) -
The OS credential store
-
A
credentialsfile in the user config directory, mode0600
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.
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-stdinreads the token from standard input and stores it; the value never appears inps, 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(orDOCSCTL_TOKEN_FILE) is read on every run and requires nologinstep 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-codeprints 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.
# 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.