# Config File Design
## Purpose
This document describes how `diffusion-cli` should support a user
configuration file.
The configuration file gives the command stable defaults for local model
files and generation parameters. This matters because the project runs
against locally stored model files and should not assume that every
machine uses the same directory layout. A single model root plus fixed
relative paths is too rigid. Each required model file should be named
independently.
The command line interface remains the highest-priority source of user
intent. The config file provides defaults, but explicit command line
options override those defaults for the current run.
## Requirements
The config file path is fixed:
```text
~/.config/diffusion.toml
```
The file uses TOML. The project requires Python 3.11 or newer, so the
implementation can use Python's standard `tomllib` module to read TOML
without adding a dependency. `tomllib` only reads TOML; it does not
write TOML. That is enough for this feature because the CLI only needs
to load user-provided defaults. See the
[tomllib documentation](https://docs.python.org/3/library/tomllib.html).
The config file should contain:
- Absolute or user-expanded paths for each model file.
- A tokenizer directory path.
- Default generation parameters such as width, height, steps, CFG,
negative prompt, output path, device, and dtype.
The config file should not contain:
- A model root used to build relative model file paths.
- Any rule that scans directories to discover model files.
- Any requirement that model files live under the same parent directory.
The command should still expose command line options for configurable
generation values. When a value appears in both the config file and the
command line, the command line value wins.
## Current State
The current path resolver is centered on a model root:
```text
diffusion_cli/paths.py
```
It defines a default model root and joins fixed relative paths under
that root. The CLI exposes `--model-root`, and the resolver also checks
`DIFFUSION_CLI_MODEL_ROOT`.
That behavior should be replaced for model files. The new behavior
should treat the diffusion model, text encoder, VAE, and tokenizer as
four independent paths.
The current generation defaults are defined as constants in:
```text
diffusion_cli/config.py
```
Those constants should remain as built-in fallback values. The config
file should override the built-in fallbacks, and command line options
should override both.
## Config Format
The file should use two top-level tables:
- `[models]` for local model and tokenizer paths.
- `[generation]` for generation defaults.
Example:
```toml
[models]
diffusion_model = "/models/z-image/diffusion.safetensors"
text_encoder = "/models/z-image/qwen_3_4b.safetensors"
vae = "/models/z-image/ae.safetensors"
tokenizer = "/home/mw/programs/ComfyUI/comfy/text_encoders/qwen25_tokenizer"
[generation]
negative_prompt = "text, watermark, full-body"
width = 832
height = 1248
batch_size = 1
steps = 10
cfg = 1.0
output = "output.png"
device = "cuda"
dtype = "auto"
```
### `[models]`
`diffusion_model`
: Path to the Z-Image diffusion model safetensors file. This value
replaces the old root-plus-relative path behavior for the diffusion
model.
`text_encoder`
: Path to the Qwen text encoder safetensors file.
`vae`
: Path to the VAE safetensors file.
`tokenizer`
: Path to the local tokenizer directory used by `transformers`.
Every model path should be parsed as a `pathlib.Path`, expanded with
`expanduser()`, resolved, and validated before generation starts. Model
files must exist and be regular files. The tokenizer path must exist,
must be a directory, and must contain the required tokenizer files.
### `[generation]`
`negative_prompt`
: Default negative prompt. This is used when `--negative-prompt` is not
passed.
`width`
: Default output width. It must be a positive multiple of the latent
downscale, currently 8.
`height`
: Default output height. It must be a positive multiple of the latent
downscale, currently 8.
`batch_size`
: Default number of images to generate. It must be at least 1.
`steps`
: Default number of denoising steps. It must be at least 1.
`cfg`
: Default classifier-free guidance scale. It must be non-negative.
`output`
: Default output image path. If the parent directory does not exist, the
CLI should create it when possible, matching the current behavior.
`device`
: Default inference device string. The current milestone is CUDA-only,
so non-CUDA values should fail with a clear unsupported-device error.
`dtype`
: Default inference dtype string. Allowed values should remain `auto`,
`bf16`, and `fp32`.
## Values That Should Stay CLI-First
`prompt` should stay CLI-first and should not be required in the config
file.
The prompt is usually different for every generation. Putting it in the
config file would make repeated invocations easier to run accidentally
with stale prompt text. The existing behavior, where generation fails
without a prompt, is clearer.
`seed` may remain CLI-only with a random default when omitted. A seed is
part of a specific generation request more often than it is a global
user preference. Keeping it CLI-only avoids accidentally making every
generation deterministic because a seed was left in the config file.
If deterministic default behavior becomes useful later, `seed` can be
added to `[generation]` without changing the rest of the merge model.
## Precedence
Effective values should be selected in this order:
1. Command line option.
2. Config file value.
3. Built-in default.
This rule is intentionally simple. The command line is closest to the
current request, so it should win. The config file represents persistent
user preferences, so it should beat built-in project defaults. Built-in
defaults are the last fallback so the command remains usable when the
config file is absent or incomplete.
For example, if the config contains:
```toml
[generation]
steps = 20
```
and the user runs:
```bash
uv run diffusion-cli --prompt "a mug" --steps 12
```
the effective step count is `12`.
If the user runs:
```bash
uv run diffusion-cli --prompt "a mug"
```
the effective step count is `20`.
If there is no config file and no `--steps` option, the effective step
count is the built-in default from `diffusion_cli/config.py`.
## Argparse Handling
The implementation must be careful with `argparse` defaults.
Today several options are registered with defaults directly in the
parser. For config merging, those parser defaults should become `None`
for values that can come from the config file. If argparse injects the
built-in value before the merge step, the program cannot tell whether
the user explicitly supplied the option or whether argparse supplied the
default.
For configurable values, parser setup should look conceptually like:
```python
parser.add_argument("--width", type=int)
parser.add_argument("--height", type=int)
parser.add_argument("--steps", type=int)
parser.add_argument("--cfg", type=float)
```
The merge layer then fills missing values:
```python
value = cli_value
if value is None:
value = config_value
if value is None:
value = built_in_default
```
This keeps command line precedence precise.
Options that are not generation defaults can keep normal argparse
behavior. For example, `--inspect-models` is a command mode flag, so it
can remain a normal boolean flag with `False` as the default.
## Loading Flow
The CLI should load and merge configuration before validation that needs
effective values.
Recommended flow:
1. Build the parser.
2. Parse command line arguments.
3. Load `~/.config/diffusion.toml` if it exists.
4. Merge command line values, config values, and built-in defaults.
5. Validate the merged model paths.
6. Validate the merged generation parameters.
7. Build immutable `ModelFiles` and `GenerationConfig` objects.
8. Continue into inspection or generation.
The config file should be optional. If it does not exist, the command
should continue with built-in defaults and command line values.
If the file exists but cannot be parsed, the command should fail
clearly. A malformed config file represents user intent that the program
cannot safely interpret.
## Proposed Code Structure
The feature can be implemented with small changes to existing modules.
### `config.py`
`config.py` should continue to own immutable value objects and
validation helpers.
Add a config-file representation such as:
```python
@dataclass(frozen=True)
class FileModelConfig:
diffusion_model: Path | None = None
text_encoder: Path | None = None
vae: Path | None = None
tokenizer: Path | None = None
@dataclass(frozen=True)
class FileGenerationConfig:
negative_prompt: str | None = None
width: int | None = None
height: int | None = None
batch_size: int | None = None
steps: int | None = None
cfg: float | None = None
output: Path | None = None
device: str | None = None
dtype: str | None = None
@dataclass(frozen=True)
class FileConfig:
models: FileModelConfig
generation: FileGenerationConfig
```
The file config objects are intentionally different from
`GenerationConfig`. File config objects represent optional raw defaults.
`GenerationConfig` represents validated effective generation intent.
Add a constant:
```python
DEFAULT_CONFIG_PATH = Path("~/.config/diffusion.toml")
```
The loader should expand this path before reading:
```python
DEFAULT_CONFIG_PATH.expanduser()
```
Add a loader:
```python
def loadFileConfig(path: Path = DEFAULT_CONFIG_PATH) -> FileConfig:
...
```
The loader should return an empty `FileConfig` if the path does not
exist. It should raise `DiffusionCliError` if the file exists but is not
valid TOML or has invalid top-level structure.
### `paths.py`
`paths.py` should stop exposing root-based model resolution as the main
path. It can keep `requireFile()` because that helper is still useful.
Replace:
```python
resolveModelFiles(model_root: Path | None = None)
```
with a function that accepts already merged path values:
```python
def validateModelFiles(
diffusion_model: Path,
text_encoder: Path,
vae: Path,
) -> ModelFiles:
...
```
This function should validate exactly the paths it receives. It should
not join them to a root.
### `cli.py`
`cli.py` should:
- Remove `--model-root`.
- Keep command line options for generation defaults.
- Keep `--tokenizer-path` as a command line override for the tokenizer
directory.
- Parse configurable options with `None` defaults.
- Load the fixed config file path.
- Merge values before building `GenerationConfig`.
- Use merged model paths for model inspection and generation.
The `--tokenizer-path` name can remain even though the config key is
`models.tokenizer`. The CLI option name is already clear to users, and
the config key belongs with the other model-loading paths.
## Error Handling
Missing config file:
: Not an error. Continue with command line values and built-in defaults.
Malformed TOML:
: Error. Include the config path and TOML parse error message.
Unknown top-level table:
: Error. This catches misspellings such as `[model]` instead of
`[models]`.
Unknown key in a known table:
: Error. This catches misspellings such as `step = 10` instead of
`steps = 10`.
Wrong value type:
: Error. For example, `steps = "10"` should fail because `steps` must be
an integer.
Missing model path after merge:
: Error. Name the missing key, such as `models.diffusion_model`.
Missing tokenizer path after merge:
: Error. Name the missing key or selected path. Tokenizer validation
should continue to report missing tokenizer files precisely.
Invalid generation value:
: Error. Reuse the existing validation messages where possible.
Unknown keys should be treated as errors instead of ignored. Ignoring
unknown keys makes config mistakes hard to find because the command
silently falls back to a different value than the user expected.
## Backward Compatibility
This feature intentionally removes the root-based model path model from
the preferred interface.
Recommended compatibility behavior:
- Remove `--model-root` from the parser.
- Remove `DIFFUSION_CLI_MODEL_ROOT` from model resolution.
- Keep the built-in concrete model paths if the project should remain
runnable on the original development machine without a config file.
The last point is a product choice. There are two reasonable options:
1. Keep built-in concrete model defaults matching the current machine.
2. Require model paths in `~/.config/diffusion.toml`.
Option 1 is more convenient for existing local usage. Option 2 is more
portable and makes local machine assumptions explicit. If the goal is to
avoid hidden path assumptions entirely, choose option 2 and make missing
model config fail clearly.
This design recommends option 2. Built-in generation defaults are stable
workflow defaults, but built-in model file paths are local machine
defaults. Local machine paths should live in the user config file.
## Tests
Add or update tests for these behaviors.
### Config Loading
- Missing `~/.config/diffusion.toml` produces an empty file config.
- Valid TOML loads `[models]` paths.
- Valid TOML loads `[generation]` values.
- Malformed TOML raises `DiffusionCliError`.
- Unknown top-level table raises `DiffusionCliError`.
- Unknown key in `[models]` raises `DiffusionCliError`.
- Unknown key in `[generation]` raises `DiffusionCliError`.
- Wrong scalar types raise `DiffusionCliError`.
### Merge Precedence
- CLI value overrides config value.
- Config value overrides built-in default.
- Built-in default is used when both CLI and config omit a generation
value.
- Missing prompt still fails for generation.
- Missing model path after merge fails with the specific config key.
### Path Validation
- Independent model paths are accepted even when they are in unrelated
directories.
- Missing diffusion model reports `Missing diffusion model`.
- Missing text encoder reports `Missing text encoder`.
- Missing VAE reports `Missing VAE`.
- Tokenizer directory validation still checks required tokenizer files.
### CLI Parser
- Configurable arguments parse to `None` when omitted.
- Explicit CLI arguments parse to their supplied values.
- `--inspect-models` remains a boolean command flag.
- `--model-root` is no longer accepted.
## Documentation Updates
Update `README.md` to describe:
- The fixed config path: `~/.config/diffusion.toml`.
- The `[models]` and `[generation]` tables.
- The command line override rule.
- The removal of `--model-root`.
The README should include a small complete TOML example and at least one
override example:
```bash
uv run diffusion-cli --prompt "a ceramic mug" --steps 12
```
The explanation should state that `--steps 12` overrides any
`generation.steps` value in the config file for that run only.
## Open Decisions
`seed`
: This design keeps `seed` CLI-only. Add it to `[generation]` later only
if deterministic default runs become a real workflow requirement.
Built-in model paths
: This design recommends requiring model paths from
`~/.config/diffusion.toml`. If preserving out-of-the-box behavior on
the current development machine is more important, the implementation
can keep built-in concrete model file defaults. It should still avoid
root-plus-relative path joining.
Environment variables
: This design does not include environment variable overrides. They can
be useful later, but adding them now creates a fourth precedence layer.
The requested behavior only needs built-ins, config, and command line
options.