Configuration

curo uses a curo.toml file at the root of your repository to define your project settings and the commands available for each profile. Components declare their own curo.toml files, one level at a time.

Below is an example curo.toml that covers common scenarios:

curo_toml_version = "2.1.0"

[about]
name = "cli"
version = "{{ inherit }}"
description = "The curo command line tool."

[dev]
build = [
  "mkdir -p build-output",
  "go build -o build-output/testcuro"
]
format = "{{ global }}"
deploy = "cp build-output/testcuro $REPO_BIN/testcuro"

[dev.test]
unit = "go test -run TestUnit ./..."
integration = "go test -run TestIntegration ./..."
functional = "poetry run py.test -vvvv tests/functional {{ args }}"

Generate a starter file with:

curo repo init > curo.toml

Top-Level Keys

The loader is strict: unknown keys and wrong shapes are loud errors. The known top-level keys are curo_toml_version, about, shell, runtime, dev, ci and rel.

  • curo_toml_version: the configuration format version. This curo speaks "2.1.0"; see Migrating for older files.
  • [about]: basic information about the component.
    • name: the component name (required).
    • version: the component version. Use "{{ inherit }}" to take the parent's version; the root cannot inherit.
    • description: a short description, shown by curo repo info -vv.
    • code_dir: the directory where sub-components live, relative to this file. Defaults to ..
    • components: an ordered list of sub-components. Order is execution order.
  • shell: the argv prefix commands run through, e.g. ["sh", "-eu", "-c"]. Defaults to ["bash", "-c"].
  • [runtime.<name>]: a named execution environment (see Runtimes below).
  • [dev], [ci] and [rel]: the three profiles (see Profiles below).

Commands

Each key under a profile table is an action from the fixed vocabulary, mapped to a command. A command takes one of three shapes:

[dev]
lint = "eslint ."                             # a string
build = ["npm ci", "npm run build"]           # a list of steps
test = { cmd = "vitest run", in = "app" }     # a table binding a runtime

A list is a command list: steps run in order, and the ledger numbers them with roman numerals.

Test Sublevels

test commands are declared per sublevel in a sub-table:

[dev.test]
unit = "go test -run TestUnit ./..."
integration = "go test -run TestIntegration ./..."
functional = "poetry run py.test tests/functional {{ args }}"

unit, integration and functional are the only sublevels, and test is the only action that has them.

Profiles

A profile is a lifecycle stage, and there are exactly three, selected at the command line with -p/--profile (default dev). Each is a delta on the one before:

  • dev: the base. The snapshot artifact, the pre-commit hook, human-legible output.
  • ci: dev plus machine manner. Reporting formats, hookless install; ci changes how commands report, never what they build.
  • rel: ci plus the release. The shippable artifact and its delivery: build, publish, deploy.

Profiles fall back down the chain: rel inherits ci, ci inherits dev. Only write a [ci] or [rel] table for commands where that stage genuinely differs:

[dev]
install = "devbox install && ./githooks/install.sh"
build = "go build -o build-output/testapp"

[ci]
install = "devbox install"   # no git hooks in a pipeline; build falls back to dev

[rel]
build = "./cross-compile-and-package.sh {{ version }}"

A pipeline test job says -p ci and still gets the dev snapshot binary by fallback; the publish pipeline says -p rel. Deploy targets (staging, prod) are not profiles; pass them as trailing arguments after --.

Runtimes

A command may declare that it runs inside a named runtime: a start/exec/stop lifecycle the manifest defines. curo ensures the runtime is up before first use and execs the command through it.

[runtime.app]
start = "docker compose up -d --wait"
exec = "docker compose exec app"
stop = "docker compose down"

[dev]
test = { cmd = "vitest run", in = "app" }   # execs inside the runtime
lint = "eslint ."                           # bare, the default

Bare, location-transparent execution is the default; provisioning the surrounding environment is explicitly someone else's job. Runtimes are for the hybrid cases, such as an app that lives in compose while its tests run on bare metal.

The lifecycle is hermetic: start runs once per runtime per invocation, and every started runtime is stopped when the invocation ends, including on failure and on interrupt.

bare is a reserved runtime name meaning no runtime at all. The declaration is the default binding, not the only one: the -r flag overrides it at execution time (see Advanced Usage).

A rule of thumb: container-as-artifact (docker build, docker push) belongs in commands; container-as-executor belongs in a runtime.

Tokens

Commands may embed tokens, which curo substitutes before execution. An unknown token is an error, never a silent substitution.

  • {{ name }}: the component's about.name.
  • {{ version }}: the component's about.version.
  • {{ args }}: the trailing arguments given after --.
  • {{ step_index }}: the 1-based action index, substituted at execution time.
  • {{ global }}: run the root's command for this action, from this component's directory. It must be the entire command string, and it composes in command lists:
[dev]
format = [
  "{{ global }}",
  "npx eslint --fix ."
]

{{ inherit }} is separate: it is valid only in about.version.

Action Scripts

When a command grows past a one-liner, put it in a script instead. curo discovers scripts at .curo/actions/<profile>.<action>[.<sublevel>].* next to the manifest and uses them as a fallback when no inline command is defined:

.curo/actions/rel.build.sh
.curo/actions/dev.test.functional.sh

Scripts are called with the positional arguments name, version and step_index, then any trailing user arguments.

Named Root Manifests

The root manifest may carry a name prefix, such as myproject.curo.toml. Only one manifest may exist per directory; multiple matches are an error.