Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,15 @@ memory:
recall_model: claude-sonnet-4-6
# embed_model: text-embedding-3-small # Only needed with openai_api_key

# Cron — no keys needed. Cron config lives in <workspace>/config/cron/
# Cron — no path keys needed. Cron config lives in <workspace>/config/cron/
# (system.yaml, jobs.yaml, gates/) and is resolved from the workspace, with a
# fallback to the legacy ~/.nerve/cron for un-migrated installs.
#
# Do NOT pin system_file/jobs_file here unless you really mean it: an explicit
# value wins outright, so it disables both the workspace location and the
# fallback, and `nerve init` will keep regenerating a system.yaml nothing reads.
#
# Editing the cron files does not apply itself: POST /api/cron/reload does.

# Workflow runs — budget-capped multi-agent jobs (Claude Workflow tool or
# Codex Ultracode) in dedicated tracked sessions. Nerve meters real dollar
Expand Down
91 changes: 91 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,97 @@ type, including `int | None`, `list[int]` and `list[str]`. So `port: ${PORT}` an
- Defaults are not re-scanned: `${A:-${B}}` yields the literal `${B}` when `A`
is unset. Use a single reference instead.

## Validating Configuration

`nerve config validate` checks a whole config bundle and exits non-zero on any
error, so it can gate a config repo in CI:

```bash
nerve config validate # the active install's config
nerve config validate --workspace . # a checked-out config repo
nerve config validate --portable-only # ignore this machine's config.yaml layers
nerve config validate --strict-keys # unknown keys become errors
nerve config validate --strict-env # every ${ENV_VAR} must be set
```

It runs even when the config cannot otherwise load, so a missing secret does not
stop it. It reports an error for:

- an unparseable or invalid cron file, a malformed `run_if` gate spec, or a bad
spec for a built-in gate;
- backend and codex misconfiguration;
- a schedule the daemon would not run as written, in `cron/*.yaml` or in
`sync.<source>.schedule`, named by job id. Either a 5-field crontab the
scheduler rejects (`99 * * * *`), which at run time the daemon only refuses
after the change has merged and synced, or a value that is neither a crontab
nor an interval (`hourly`, `@daily`), which silently becomes a fixed 2-hour
cadence. Write a crontab (`*/15 * * * *`) or an interval (`4h`, `30m`,
`1h30m`, `90s`);
- a path setting written `.` or `./`: `workspace`, `cron.jobs_file`,
`cron.system_file`, `cron.gate_plugins_dir`, `gateway.ssl.cert`,
`gateway.ssl.key`, `proxy.binary_path`, `proxy.auth_dir`, `proxy.log_file` or
`workflows.runs_dir`. A dot is a path like any other, so it aims the setting at
whatever directory the daemon was started in. Write the path out, or omit the
key to get the default — a blank value is *unset* (above) and is not an error.
This matters most for `cron.gate_plugins_dir`, whose `.py` files the daemon
imports and executes at startup and on every cron reload.

Validation never imports those gate plugins, because checking them would mean
running the bundle it is judging. A `run_if` entry naming a gate type that is not
built in is therefore reported as a warning: validation can confirm neither that
the type exists nor that the spec's fields are right. Gate plugins are code, so
test them as code.

Two checks are lenient by default, so that validating a live install does not cry
wolf:

| Flag | Default | With the flag |
|------|---------|---------------|
| `--strict-keys` | An unknown or misspelled key is a warning. Covers config keys and the fields of a built-in gate's `run_if` spec. | Unknown keys are errors. |
| `--strict-env` | An unset `${ENV_VAR}` is info, since CI has no secrets. | Every reference must resolve. |

Turn `--strict-keys` on in CI. A typo'd key is the most common config mistake and
the quietest, because nothing reads it.

`--portable-only` judges the tracked `<workspace>/config/settings.yaml` layer on
its own. Use it for a change headed to a shared repo: a local override can
otherwise mask an invalid shared value, and, more often, a broken local file can
fail a shared bundle that has nothing wrong with it. Pass `--workspace` with it,
because with the machine layers dropped there is nothing left to read the
workspace location from and it falls back to the default tree. It fails outright
if it opened no file under the workspace's `config/`, so an empty directory, a
`settings.yml` typo or a `settings.yaml` left at the repo root cannot pass as
clean. Every run names the layers it read, in absolute paths:

```
[info] portable layer: /home/you/config-repo/config/settings.yaml
[info] machine-local layers (config.yaml, config.local.yaml) not read: validating the portable workspace config on its own
```

Without `--portable-only` the second line names the machine-local files that were
overlaid instead, and the directory they came from.

In CI, install nerve and run the same command:

```yaml
jobs:
validate-config:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
# nerve is not published to PyPI: the bare name is an unrelated project.
- run: uv pip install --system "nerve @ git+https://github.com/ClickHouse/nerve"
- run: nerve config validate --workspace . --portable-only --strict-keys
```

Installing from the default branch keeps the validator at or ahead of your
instance, which is the safe direction for `--strict-keys`: a newer validator
knows every key your instance reads. A key the validator accepts but the instance
is too old to read shows up as a startup warning on the instance and in `nerve
doctor`. If a key is ever retired upstream, `--strict-keys` flags it here first;
drop the key, or install the ref you deploy (`nerve @ git+...@v1.2.3`) instead.

## Config Directory Resolution

`nerve` commands locate the config directory via a waterfall, so they work
Expand Down
2 changes: 1 addition & 1 deletion docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ prompt definition.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | yes | Unique job identifier. `cleanup`, `wakeup_sweep` and anything starting with `source:` are **reserved by the daemon** — a job using one is skipped (with a warning naming it in the daemon log) and never scheduled, so rename it |
| `schedule` | string | yes | Crontab expression or interval (`2h`, `30m`, `1h30m`, `0.5h`) — see [Interval syntax](#interval-syntax). A 5-field crontab with an out-of-range field (`99 * * * *`) is **rejected**, never reinterpreted as an interval — reload returns `400` and startup skips that one job with an error in the daemon log |
| `schedule` | string | yes | Crontab expression or interval (`2h`, `30m`, `1h30m`, `0.5h`) — see [Interval syntax](#interval-syntax). A 5-field crontab with an out-of-range field (`99 * * * *`) is **rejected**, never reinterpreted as an interval — reload returns `400` and startup skips that one job with an error in the daemon log. Anything that names no usable interval (`hourly`, `@daily`, `1h junk`, or a zero like `0h`) silently becomes a 2-hour interval at run time; `nerve config validate` fails on both, which is the only warning you get about the second |
| `prompt` | string | yes* | Message sent to the agent |
| `prompt_file` | string | yes* | Path to a file containing the prompt (relative to the YAML's directory). Read fresh each run; shareable between jobs. *One of `prompt`/`prompt_file` is required |
| `description` | string | no | Human-readable description |
Expand Down
92 changes: 78 additions & 14 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

from nerve import paths
from nerve.config import (
ConfigError,
RESUME_QUEUE_FILE,
load_config,
resolve_config_dir,
Expand Down Expand Up @@ -173,7 +172,7 @@ def _docker_compose(
# Commands that must keep working when the config itself is broken -- they are
# how an operator diagnoses or repairs it. They receive ctx.obj["config"] = None
# and ctx.obj["config_error"], and are responsible for reporting.
_SELF_DIAGNOSING_COMMANDS = frozenset({"doctor", "init"})
_SELF_DIAGNOSING_COMMANDS = frozenset({"config", "doctor", "init"})


@click.group()
Expand All @@ -196,17 +195,27 @@ def main(ctx: click.Context, config_dir: str | None, verbose: bool) -> None:
config_error = None
try:
config = load_config(resolved_dir)
except ConfigError as e:
set_config(config)
except Exception as e:
# This callback runs before *every* subcommand, so a config that won't
# load would otherwise take down the commands you reach for to fix it.
# The self-diagnosing ones get config=None and report the problem
# themselves; everything else gets a clean message and a non-zero exit
# rather than a raw traceback.
# The self-diagnosing ones get config=None plus the message and report
# it themselves — for any failure at all, since a command whose job is to
# explain a broken config is the last place a traceback helps.
#
# Everything else gets a clean error and a non-zero exit, but only for
# the operator's own mistakes: ConfigError (malformed YAML, an
# unresolved required ${VAR}) and the plain ValueError that
# NerveConfig._validate_backend_config raises for a bad agent.backend or
# codex section — ConfigError subclasses ValueError, so one check covers
# both. Anything else means nerve itself is broken, not the config, and
# keeps its traceback: presenting an internal defect as "Error: <config
# problem>" sends the operator to edit a file that was never at fault.
if ctx.invoked_subcommand not in _SELF_DIAGNOSING_COMMANDS:
raise click.ClickException(str(e)) from e
config_error = e
if config is not None:
set_config(config)
if isinstance(e, ValueError):
raise click.ClickException(str(e)) from e
raise
config_error = str(e)
ctx.ensure_object(dict)
ctx.obj["config"] = config
ctx.obj["config_error"] = config_error
Expand Down Expand Up @@ -1066,11 +1075,12 @@ def doctor(ctx: click.Context) -> None:
config = ctx.obj["config"]
if config is None:
# The config wouldn't load at all — that *is* the diagnosis.
click.secho("Nerve Doctor", bold=True)
click.echo("Nerve Doctor")
click.echo("=" * 40)
click.secho(f"[ERR] Config could not be loaded: {ctx.obj['config_error']}",
fg="red")
click.echo(f" Config directory: {ctx.obj['config_dir']}")
click.echo(f"Config dir: {ctx.obj.get('config_dir')}")
click.secho(
f"[ERR] config failed to load: {ctx.obj.get('config_error')}", fg="red"
)
ctx.exit(1)
report = doctor_report(
config,
Expand All @@ -1082,6 +1092,60 @@ def doctor(ctx: click.Context) -> None:
ctx.exit(1)


@main.group(name="config")
def config_group() -> None:
"""Configuration commands."""


@config_group.command("validate")
@click.option(
"--workspace", "workspace", type=click.Path(), default=None,
help="Validate this workspace's config (e.g. a checked-out config repo: "
"--workspace .). Defaults to the resolved workspace.",
)
@click.option(
"--strict-env", is_flag=True,
help="Fail if any ${ENV_VAR} reference is unset. Default: allow (CI usually "
"has no secrets); unset refs are reported as info.",
)
@click.option(
"--strict-keys", is_flag=True,
help="Fail on unknown/misspelled config keys. Default: warn (a key from a "
"newer nerve shouldn't fail CI). Recommended in CI on a config repo, "
"where the validator is pinned to the deployed nerve version.",
)
@click.option(
"--portable-only", is_flag=True,
help="Ignore this machine's config.yaml / config.local.yaml and validate "
"only the portable workspace config — what a shared repo carries.",
)
@click.pass_context
def config_validate(
ctx: click.Context, workspace: str | None, strict_env: bool, strict_keys: bool,
portable_only: bool,
) -> None:
"""Validate the configuration bundle. Non-zero exit on any error (CI-ready)."""
from nerve.config_validate import validate_config_bundle

config_dir = Path(ctx.obj["config_dir"])
result = validate_config_bundle(
config_dir, workspace_override=workspace,
strict_env=strict_env, strict_keys=strict_keys,
portable_only=portable_only,
)
for msg in result.info:
click.echo(f"[info] {msg}")
for msg in result.warnings:
click.secho(f"[WARN] {msg}", fg="yellow")
for msg in result.errors:
click.secho(f"[ERR] {msg}", fg="red")
if result.ok:
click.secho("Config OK", fg="green")
else:
click.secho(f"Config invalid: {len(result.errors)} error(s)", fg="red")
ctx.exit(1)


@main.group()
def codex() -> None:
"""Inspect and authenticate the Codex integration."""
Expand Down
Loading
Loading