# Plan: Configuration Files

Support persistent settings via TOML config files at two levels: user-global (`~/.config/swival/config.toml`) and project-local (`swival.toml` in `base_dir`). Zero new dependencies — Python 3.13 ships `tomllib`.

## Precedence

CLI flags > project config > global config > hardcoded defaults.

Each layer is optional. Values merge shallowly — a key in a higher-priority layer replaces the same key from a lower layer entirely (no deep-merging of lists/dicts).

## File locations

| Level | Path | Created by |
|---|---|---|
| Global | `~/.config/swival/config.toml` | User (manually or `swival --init-config`) |
| Project | `<base_dir>/swival.toml` | User (manually or `swival --init-config --project`) |

Global path respects `XDG_CONFIG_HOME`: `os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")`.

Why `swival.toml` in the project root (not `.swival/config.toml`): it's visible, version-controllable, and follows the convention of `pyproject.toml`, `ruff.toml`, etc. The `.swival/` directory stays runtime-only (trash, history, todo).

## Config schema

The config file uses **CLI-style key names** as its canonical form. This is the representation that `load_config()` returns and that the CLI consumer (`apply_config_to_args`) consumes directly. A separate `config_to_session_kwargs()` adapter translates to Session's different naming when needed.

```toml
# --- Provider / model ---
provider = "openrouter"          # "lmstudio" | "huggingface" | "openrouter"
model = "qwen/qwen3-235b-a22b"
api_key = "sk-or-..."            # prefer env vars; this is a fallback
base_url = "https://..."

# --- Generation parameters ---
max_output_tokens = 32768
max_context_tokens = 131072
temperature = 0.7
top_p = 1.0
seed = 42

# --- Agent behaviour ---
max_turns = 50
system_prompt = "You are a helpful assistant."  # mutually exclusive with no_system_prompt
no_system_prompt = false

# --- Sandbox / security ---
allowed_commands = ["ls", "git", "python3"]     # TOML array (not comma-string)
yolo = false
allowed_dirs = ["../shared-lib", "/data/assets"]  # TOML array
no_read_guard = false

# --- Features ---
no_instructions = false
no_skills = false
skills_dir = ["../my-skills"]                     # TOML array
no_history = false

# --- UI ---
color = true       # true = force color, false = force no-color, absent = auto
quiet = false

# --- External ---
reviewer = "./review.sh"
```

### Key naming: two worlds

The config, CLI (argparse), and Session API use different names for some settings. There is one canonical config schema, and explicit mappings to each consumer.

**Config → argparse dest** (used by `apply_config_to_args()`):

| Config key | argparse dest | Notes |
|---|---|---|
| `allowed_dirs` | `allow_dir` | argparse `--allow-dir` has dest `allow_dir` |
| `color` | `color` + `no_color` | Single config key controls mutual-exclusive pair |
| everything else | same name | Identity mapping |

**Config → Session kwargs** (used by `config_to_session_kwargs()`):

| Config key | Session kwarg | Transformation |
|---|---|---|
| `allowed_dirs` | `allowed_dirs` | Identity (Session already uses this name) |
| `no_read_guard` | `read_guard` | `read_guard = not no_read_guard` |
| `no_history` | `history` | `history = not no_history` |
| `quiet` | `verbose` | `verbose = not quiet` |
| `color` | *(dropped)* | Not a Session concern |
| `reviewer` | *(dropped)* | Not a Session concern |
| everything else | same name | Identity mapping |

This means `load_config()` returns config-canonical keys, and each consumer calls the appropriate adapter. No ambiguity about which namespace a dict belongs to.

### Mutual-exclusion validation in config

If a config file sets both `system_prompt` and `no_system_prompt = true`, that's a conflict. `load_config()` validates this and raises `ConfigError`:

```
ConfigError: swival.toml: 'system_prompt' and 'no_system_prompt' are mutually exclusive
```

This is the only mutual-exclusion pair in config. The `color` setting is a single boolean key (not a pair), so no conflict is possible there.

### Type validation: list element types

`CONFIG_KEYS` tracks both the container type and the element type for lists:

```python
CONFIG_KEYS: dict[str, type | tuple[type, ...]] = {
    "provider": str,
    "model": str,
    "api_key": str,
    "base_url": str,
    "max_output_tokens": int,
    "max_context_tokens": int,
    "temperature": (int, float),   # TOML int is valid for a float field
    "top_p": (int, float),
    "seed": int,
    "max_turns": int,
    "system_prompt": str,
    "no_system_prompt": bool,
    "allowed_commands": list,       # validated separately: all elements must be str
    "yolo": bool,
    "allowed_dirs": list,           # validated separately: all elements must be str
    "no_read_guard": bool,
    "no_instructions": bool,
    "no_skills": bool,
    "skills_dir": list,             # validated separately: all elements must be str
    "no_history": bool,
    "color": bool,
    "quiet": bool,
    "reviewer": str,
}

# Keys whose list elements must be strings.
_LIST_OF_STR_KEYS = {"allowed_commands", "allowed_dirs", "skills_dir"}
```

Validation checks:
1. Outer type: `isinstance(value, list)`.
2. Element type: every element must be `str`. If any element is not a string, raise `ConfigError` with a clear message like `"allowed_commands[2]: expected string, got int"`.

This catches `allowed_commands = ["ls", 42, true]` at load time instead of crashing inside `resolve_commands()` or `Path(...)`.

### Relative path resolution for config-sourced paths

Config-provided paths in `allowed_dirs`, `skills_dir`, and `reviewer` resolve **relative to the config file's parent directory**, not cwd or `--base-dir`. This matches how paths in `pyproject.toml` and similar files work — the config is portable regardless of where you invoke the CLI from.

Implementation: `load_config()` takes `base_dir` (for finding project config) and tracks which file each path-valued key came from. After merging, it resolves relative paths in `allowed_dirs`, `skills_dir`, and `reviewer` against the source file's directory:

```python
def _resolve_paths(config: dict, config_dir: Path) -> None:
    """Resolve relative paths in config against the config file's parent directory.

    Applies expanduser() before checking is_absolute(), so that ~/... paths
    expand to the user's home directory instead of becoming <config_dir>/~/...
    """
    for key in ("allowed_dirs", "skills_dir"):
        if key in config:
            config[key] = [
                str(Path(p).expanduser() if Path(p).expanduser().is_absolute()
                    else config_dir / p)
                for p in config[key]
            ]
    if "reviewer" in config:
        r = Path(config["reviewer"]).expanduser()
        if not r.is_absolute():
            r = config_dir / config["reviewer"]
        config["reviewer"] = str(r)
```

Path resolution happens per-file *before* merging, so global config paths resolve relative to `~/.config/swival/` and project config paths resolve relative to `<base_dir>/`.

After resolution, the downstream code in `_run_main()` (which does `Path(d).expanduser().resolve()` on `allow_dir` entries) and `discover_skills()` continues to work unchanged — it receives already-anchored paths.

### Type coercion: `allowed_commands`

On CLI, `--allowed-commands` takes a comma-separated string. In config, it's a TOML array. The `resolve_commands()` function in `agent.py` currently expects a comma-string.

**Chosen approach:** Change `resolve_commands()` to accept `str | list[str] | None`. If it receives a list, use it directly. If it receives a string, split on commas. Same normalization applies to `_report_settings()` which does `(args.allowed_commands or "").split(",")` — guard that with an `isinstance` check. Also update `Session._setup()` which currently does `",".join(self.allowed_commands)` before calling `resolve_commands()` — with the new signature, it can pass the list directly.

### Keys excluded from config

These are inherently per-invocation and don't belong in a config file:

- `question` — the task itself
- `--repl` — session mode
- `--version` — action flag
- `--report FILE` — output path
- `--base-dir` — almost always `.`; set per invocation
- `--init-config` — the bootstrapping command itself

## Implementation

### New file: `swival/config.py`

Single module, ~200 lines. Public API:

```python
_UNSET = object()  # Sentinel for "not set by CLI"

DEFAULTS: dict[str, Any] = {
    "provider": "lmstudio",
    "model": None,
    "api_key": None,
    "base_url": None,
    "max_output_tokens": 32768,
    "max_context_tokens": None,
    "temperature": None,
    "top_p": 1.0,
    "seed": None,
    "max_turns": 100,
    "system_prompt": None,
    "no_system_prompt": False,
    "allowed_commands": None,      # None on CLI (comma-string), list in config
    "yolo": False,
    "allowed_dirs": [],
    "no_read_guard": False,
    "no_instructions": False,
    "no_skills": False,
    "skills_dir": [],
    "no_history": False,
    "color": False,
    "no_color": False,
    "quiet": False,
    "reviewer": None,
}


def load_config(base_dir: Path) -> dict:
    """Load and merge global + project config.

    Returns a flat dict with config-canonical keys. Only keys that were
    actually set in config files are included (no defaults injected).
    Validates types and mutual exclusions. Resolves relative paths
    against each config file's parent directory.
    """


def apply_config_to_args(args: argparse.Namespace, config: dict) -> None:
    """Apply config values to argparse namespace where CLI didn't set a value.

    For each config key, maps to the argparse dest name and checks if
    the value is still _UNSET. If so, applies the config value. After
    processing all config keys, sweeps remaining _UNSET sentinels and
    replaces them with hardcoded defaults from DEFAULTS.
    """


def config_to_session_kwargs(config: dict) -> dict:
    """Convert config dict to Session constructor kwargs.

    Translates config-canonical keys to Session's naming conventions:
    no_read_guard → read_guard (inverted), no_history → history (inverted),
    quiet → verbose (inverted). Drops keys that aren't Session concerns
    (color, reviewer).
    """


def generate_config(project: bool = False) -> str:
    """Return a commented-out template config string."""
```

#### `load_config()` steps:

For each config file (global then project):

1. Read the TOML file if it exists; skip if missing.
2. **Validate first:** check keys against `CONFIG_KEYS`, validate types (including list element types via `_LIST_OF_STR_KEYS`), check mutual exclusions (`system_prompt` + `no_system_prompt`). Unknown keys → warning on stderr (forward-compatible). Wrong type → `ConfigError` with a clear message. This runs *before* path resolution so that `allowed_dirs = [1]` raises `ConfigError("allowed_dirs[0]: expected string, got int")` instead of crashing with `TypeError` inside `Path(1)`.
3. **Then resolve paths:** call `_resolve_paths(config, config_file_parent)` to anchor relative paths.

After processing both files:

4. Merge: start with global, update with project (shallow).
5. Return the merged dict (only keys that were actually set — no defaults injected).

#### `apply_config_to_args()` steps:

This is the key integration point. Operates on the argparse namespace after parsing but before any business logic runs.

For each key in the config dict:
1. Map config key to argparse dest name (using `_CONFIG_TO_ARGPARSE`, identity if not in map).
2. Check if the argparse value is still `_UNSET`. If so, apply the config value.
3. If the argparse value is not `_UNSET`, the user passed it on CLI — skip.

Special case for `color`:
- Config `color = true` → set `args.color = True, args.no_color = False` (if both are still `_UNSET`).
- Config `color = false` → set `args.color = False, args.no_color = True` (if both are still `_UNSET`).
- If either `--color` or `--no-color` was passed on CLI (not `_UNSET`), skip — CLI wins.

After processing all config keys, sweep the full namespace and replace any remaining `_UNSET` values with hardcoded defaults. The sweep must be dest-aware — it iterates over `_ARGPARSE_DEFAULTS`, a dedicated map from argparse dest names to their default values, not config-canonical keys:

```python
# Mapping from argparse dest names to their hardcoded defaults.
# Covers every configurable arg, using the dest name that argparse actually stores.
_ARGPARSE_DEFAULTS: dict[str, Any] = {
    "provider": "lmstudio",
    "model": None,
    "api_key": None,
    "base_url": None,
    "max_output_tokens": 32768,
    "max_context_tokens": None,
    "temperature": None,
    "top_p": 1.0,
    "seed": None,
    "max_turns": 100,
    "system_prompt": None,
    "no_system_prompt": False,
    "allowed_commands": None,
    "yolo": False,
    "allow_dir": [],        # note: argparse dest, not config key "allowed_dirs"
    "no_read_guard": False,
    "no_instructions": False,
    "no_skills": False,
    "skills_dir": [],
    "no_history": False,
    "color": False,
    "no_color": False,
    "quiet": False,
    "reviewer": None,
}
```

This ensures that `args.allow_dir` (not `args.allowed_dirs`) gets swept to `[]`, and `args.color` / `args.no_color` are both swept independently. The sweep is:

```python
for dest, default in _ARGPARSE_DEFAULTS.items():
    if getattr(args, dest, _UNSET) is _UNSET:
        setattr(args, dest, default)
```

The parser no longer needs to be passed in — `apply_config_to_args` just checks for `_UNSET`, which is unambiguous.

### Changes to `swival/agent.py`

#### Sentinel defaults in `build_parser()`

All configurable arguments get `default=_UNSET` instead of their current hardcoded defaults. The hardcoded defaults live in `DEFAULTS` in `config.py` (single source of truth). Non-configurable arguments (`--repl`, `--version`, `--report`, `--base-dir`, `--init-config`, `--project`) keep their current defaults.

```python
from .config import _UNSET

# In build_parser():
parser.add_argument("--max-turns", type=int, default=_UNSET, ...)
parser.add_argument("--yolo", action="store_true", default=_UNSET, ...)
# etc.
```

**Boolean flags with `action="store_true"`:** argparse `store_true` with `default=_UNSET` will set the value to `True` when the flag is present, and leave it as `_UNSET` when absent. This is exactly the behavior we need — `_UNSET` means "not set on CLI, config can fill in."

**Mutual-exclusive groups (`--color`/`--no-color`, `--system-prompt`/`--no-system-prompt`):** Both members get `default=_UNSET`. If neither is passed, both stay `_UNSET` → config can fill in.

#### `--init-config` handling

Add `--init-config` as a `store_true` flag and `--project` as a companion boolean. Both keep their own fixed defaults (not sentinel — they're non-configurable). Handle in `main()` **before** the question-required check, right after `--version`:

```python
def main():
    parser = build_parser()
    args = parser.parse_args()

    if args.version:
        # ... existing version handling, sys.exit(0)

    if getattr(args, "init_config", False):
        _handle_init_config(args)
        sys.exit(0)

    # Now: load config, apply to args, resolve sentinels to defaults
    base_dir = Path(args.base_dir).resolve()
    file_config = load_config(base_dir)
    apply_config_to_args(args, file_config)

    # Derived values (after all sentinels are resolved)
    args.verbose = not args.quiet

    if not args.repl and args.question is None:
        parser.error("question is required (or use --repl)")
    # ... rest of validation
```

Note: `_handle_init_config` also uses `getattr(args, "project", False)` internally. Both attributes use `getattr` with `False` fallback so that test helpers constructing `SimpleNamespace` without these fields don't crash. The actual `build_parser()` defines both with `default=False` (non-sentinel), so this is purely a safety net for monkeypatched tests.

This ensures `--init-config` works without a question, and config loading happens before any validation that depends on resolved values.

#### `resolve_commands()` signature change

```python
def resolve_commands(
    allowed_commands: str | list[str] | None,
    yolo: bool,
    base_dir: str,
) -> dict[str, str]:
    if yolo:
        return {}
    if isinstance(allowed_commands, list):
        allowed_names = {c.strip() for c in allowed_commands if c.strip()}
    elif allowed_commands:
        allowed_names = {c.strip() for c in allowed_commands.split(",") if c.strip()}
    else:
        allowed_names = set()
    # ... rest unchanged
```

Same guard in `_report_settings()`:

```python
cmds = args.allowed_commands
if isinstance(cmds, list):
    cmd_list = sorted(cmds)
elif cmds:
    cmd_list = sorted(c.strip() for c in cmds.split(",") if c.strip())
else:
    cmd_list = []
```

### Changes to `swival/session.py`

`Session` does **not** auto-load config files. Config loading is a CLI concern. The library API stays fully explicit — callers pass exactly the kwargs they want.

Rationale: auto-loading makes library behavior environment-dependent. A developer's `swival.toml` or `~/.config/swival/config.toml` would silently alter programmatic API results, breaking test isolation and making behavior unpredictable for library consumers.

If a library user wants config file support, they call `load_config()` + `config_to_session_kwargs()` explicitly:

```python
from swival.config import load_config, config_to_session_kwargs
from swival.session import Session

config = load_config(Path("."))
session = Session(**config_to_session_kwargs(config))
# or merge with their own overrides:
kwargs = config_to_session_kwargs(config)
kwargs["model"] = "my-override"
session = Session(**kwargs)
```

Also update `Session._setup()` to pass `self.allowed_commands` (a `list[str] | None`) directly to `resolve_commands()` instead of joining into a comma-string first, since `resolve_commands()` now accepts both forms.

### Existing test updates

The sentinel default change affects existing tests in two ways:

#### 1. Parser default assertions

Tests that assert `args.yolo is False` or `args.no_read_guard is False` after `parser.parse_args(["question"])` will now get `_UNSET` instead. These tests must be updated.

Affected (6 assertions in 4 files):
- `tests/test_yolo.py:496` — `assert args.yolo is False` → `assert args.yolo is _UNSET`
- `tests/test_yolo.py:491` — `assert args.yolo is True` (unchanged — flag was passed)
- `tests/test_read_guard.py:461` — `assert args.no_read_guard is False` → `assert args.no_read_guard is _UNSET`
- `tests/test_read_guard.py:453` — `assert args.no_read_guard is True` (unchanged)
- `tests/test_report.py:308` — `assert args.report is None` (unchanged — `--report` keeps its real default)
- `tests/test_logging.py:235-245` — these test `args.verbose` which is derived (`not args.quiet`). The derivation now happens after `apply_config_to_args`, so these tests should call `apply_config_to_args(args, {})` first (empty config, resolves sentinels to defaults), then check `args.verbose`.

#### 2. SimpleNamespace `_base_args()` helpers

Nine test helpers construct `SimpleNamespace` args objects to pass to `_run_main()` or `main()`. These bypass `build_parser()` entirely, so they never see `_UNSET` sentinels — they set concrete values directly. **These continue to work unchanged** because:

- `_run_main()` consumes `args` after config resolution is complete (config loading happens in `main()` before `_run_main()` is called).
- Tests that monkeypatch `parse_args` and call `main()` directly need two new fields: `init_config=False` and `project=False`. Add these to the helpers.

Affected helpers (need `init_config=False` and `project=False` added):
- `tests/test_reviewer.py:27-60` (`_base_args`)
- `tests/test_reviewer.py:234-244` (inline `SimpleNamespace`)
- `tests/test_guardrails.py:26-58` (`_base_args`)
- `tests/test_logging.py:32-65` (`_base_args`)
- `tests/test_todo.py:536-568` (`_base_args`)
- `tests/test_report.py:473-504` (`_base_args`)
- `tests/test_delete.py:471-501` (inline)
- `tests/test_history.py:244-278` (`_base_args`)
- `tests/test_repl.py:104-114` (inline `SimpleNamespace`)

Use `getattr(args, "init_config", False)` in `main()` instead of direct attribute access, so existing test helpers that lack the field don't crash. This is safer and means we don't strictly need to update all helpers on day one — they'll work either way. Update them for cleanliness but `getattr` is the safety net.

### Test file: `tests/test_config.py`

Cover:

**Config loading:**
- Global-only config loads correctly
- Project-only config loads correctly
- Both present: project overrides global (shallow merge)
- Missing files → empty dict (no error)
- Unknown keys → warning, not error
- Wrong type → `ConfigError` with clear message
- `generate_config()` output is valid TOML (round-trip through `tomllib`)

**Type validation:**
- String where int expected → `ConfigError`
- Mixed-type list `["ls", 42]` → `ConfigError` naming the index
- Empty list → valid
- TOML int for float field (e.g. `temperature = 1`) → valid (coerced)

**Mutual exclusion:**
- `system_prompt = "x"` + `no_system_prompt = true` → `ConfigError`
- `system_prompt = "x"` alone → valid
- `no_system_prompt = true` alone → valid

**Key normalization:**
- `allowed_dirs` in config maps to `allow_dir` on argparse namespace
- `skills_dir` in config maps correctly
- `config_to_session_kwargs()` translates `no_read_guard → read_guard`, `no_history → history`, `quiet → verbose`, drops `color`/`reviewer`
- `config_to_session_kwargs()` output is accepted by `Session(**kwargs)` without error

**Path resolution:**
- Relative `allowed_dirs` entry resolves against config file parent, not cwd
- Absolute `allowed_dirs` entry is unchanged
- `~/...` paths expand to home dir (not anchored to config dir)
- Global config paths resolve against `~/.config/swival/`
- Project config paths resolve against `<base_dir>`
- `reviewer` relative path resolves against config file parent
- `reviewer = "~/bin/review.sh"` expands to home, not `<config_dir>/~/bin/review.sh`
- Path resolution only runs after type validation passes (non-string list elements don't crash `Path()`)

**CLI integration (`apply_config_to_args`):**
- Config values fill in when CLI flag is absent (sentinel)
- Explicit CLI flag beats config value
- Sentinel defaults resolve to hardcoded defaults when neither CLI nor config sets them
- `store_true` booleans: flag absent + config `true` → `True`; flag absent + no config → default `False`
- Mutual-exclusive color group: config `color = true` → `args.color=True, args.no_color=False`
- Mutual-exclusive color group: config `color = false` → `args.color=False, args.no_color=True`
- Mutual-exclusive color group: no config → auto-detect (both at defaults)
- `allowed_commands` as TOML array flows through to `resolve_commands()` without crash

**`--init-config`:**
- `swival --init-config` writes global config without requiring a question
- `swival --init-config --project` writes to `<base_dir>/swival.toml`
- Refuses to overwrite existing config (prints message, exits non-zero)

**`resolve_commands` accepts both types:**
- String input (existing behavior)
- List input (config path)
- None input (no commands)

**`_report_settings` handles both types:**
- `args.allowed_commands` as string
- `args.allowed_commands` as list

**Security:**
- `api_key` in project config triggers a warning if inside a git repo

## Work order

1. Add `swival/config.py` — `_UNSET`, `DEFAULTS`, `CONFIG_KEYS`, `load_config()`, `apply_config_to_args()`, `config_to_session_kwargs()`, `generate_config()`.
2. Add `tests/test_config.py` — config loading, type validation, mutual exclusion, path resolution, key normalization, `config_to_session_kwargs`, and `generate_config` tests.
3. Modify `build_parser()` — sentinel defaults for configurable args, add `--init-config` and `--project` flags.
4. Modify `main()` — handle `--init-config` early (with `getattr` safety), call `load_config()` + `apply_config_to_args()`, derive `verbose` after resolution.
5. Modify `resolve_commands()` — accept `str | list[str] | None`.
6. Modify `_report_settings()` — guard `allowed_commands` with `isinstance`.
7. Modify `Session._setup()` — pass `allowed_commands` list directly to `resolve_commands()`.
8. Update existing test assertions for sentinel defaults; add `init_config`/`project` to `_base_args` helpers.
9. Add integration tests for the full CLI → config → resolution path.
10. Update CLAUDE.md architecture section.

## Open questions

- **Should `api_key` be allowed in project-level config?** Convenient for dedicated endpoints but risky if committed. Plan: allow it, but print a stderr warning if the project config contains `api_key` and the project dir is inside a git repo (check for `.git`).
