Introduction

curo: /ˈkuː.roː/, [ˈkuːroː]

  1. to arrange, see to, attend to, take care of, look after, ensure, tend to
  2. to heal, cure
  3. to govern, command, preside over

curo is a command line tool for managing development environments, for humans and for automation. It ships a fixed, language-agnostic vocabulary of actions such as build, test and deploy, so you never have to invent your own script names or targets. A curo.toml manifest in your repository maps each action to the real commands. Think of curo as similar to tools like Make, Just and Please, with hints of what build systems like Bazel and Pants have explored over the years.

The main takeaway of curo is that it does not force a particular toolchain for the language(s) you are developing in. You set up the environment, and curo gives every part of your repository the same set of levers.

Why would I use curo?

There are many tools that exist to run commands and structure codebases. The philosophy behind curo is to standardize the language used when building code projects and to reduce the complexity of managing different components. The action vocabulary is deliberately fixed: curo build means the same thing in every repository that uses curo, whatever is happening underneath.

Key Benefits

  • Consistent Commands: Everyone on your team runs curo build, curo test, etc. No more "what do I run here?".
  • Portable Configuration: All logic lives in curo.toml files alongside your code, and you can always see what the actual commands are.
  • No Language Lock-in: You are not tied to any specific toolchain; you define how commands run.
  • Smooth for CI/CD: The same commands work everywhere. The ci and rel profiles cover the cases where machine reporting or the release artifact genuinely differ.
  • Friendly to Automation: --plan renders what would run without running it, and --porcelain emits machine-readable JSON. Agents get honest exit codes and structured output.
# Build all components
curo build

# Run unit tests
curo test.unit

# Deploy a component
curo deploy myapp/api

Project configuration (in curo.toml):

curo_toml_version = "2.1.0"

[about]
name = "myapp"
version = "1.0.0"

[dev]
build = "go build -o bin/myapp ."

[dev.test]
unit = "go test ./..."

Installation

Run the following command in your terminal:

curl -sfL "https://gitlab.com/scottlindeman/public/-/raw/main/01_projects/curo/install" | bash

This will install the latest curo version into $HOME/.local/bin. If you want a specific version, use the following bash syntax

bash -s -- -v "2.1.0"

If another install directory is preferable, use the following bash syntax

bash -s -- -i "/usr/local/bin"

Example:

curl -sfL "https://gitlab.com/scottlindeman/public/-/raw/main/01_projects/curo/install" | bash -s -- -v "2.1.0" -i "/usr/local/bin"

This will install version 2.1.0 of the curo binary to /usr/local/bin.

Shell Completion

curo generates its own completion scripts for fish, bash and zsh. Component paths, actions, sublevels, profiles and runtime names all complete, so this is worth the minute of setup.

For fish:

curo repo completion fish > ~/.config/fish/completions/curo.fish

For bash, add to your ~/.bashrc:

source <(curo repo completion bash)

For zsh, add to your ~/.zshrc:

source <(curo repo completion zsh)

Usage

Once installed, curo provides a simple, unified CLI for common development workflow tasks. Typical commands are:

curo build         # Build your project or component(s)
curo test          # Run tests
curo deploy        # Deploy built artifacts

You can always see the available actions by running:

curo --help

Command Structure

Every invocation follows one grammar:

curo [flags] <action> [component-path] [-- args...]
  • <action>: one of the standard actions below, as a single token.
  • [component-path]: optionally target one component, addressed by a slash path such as curo/cli. No path means the whole tree from the root.
  • [-- args...]: everything after -- is passed through to the underlying command untouched.
  • flags can go anywhere; see Advanced Usage.

The Actions

The action vocabulary is fixed. Every action has a short alias.

ActionAliasMeaning
installiInstall the environment
buildbBuild some code
lintlCheck for linting and typing code
formatfFormat some code
generategGenerate code from a template or specification
cleancClean up the environment
startstStart an application detached
runrnExecute and tail an application
stopspStop a detached application
restartrQuickly restart an application
statusssCheck whether any applications are running
testtTest some code
loglgTail the logs of a running application
publishpPublish an artifact
deploydDeploy the artifact

There are no user-defined actions. If a component needs a command, it lives under one of these names.

Common Workflow

  1. Install project dependencies

    curo install
    
  2. Build the project

    curo build
    
  3. Run tests

    curo test
    
  4. Start your project

    curo start
    

Targeting a Component

Component paths are single tokens with slashes, and they tab-complete:

curo build curo/cli
curo start backend/server

Test Sublevels

test is the one action that fans out. A bare curo test runs every defined sublevel in order: unit, integration, then functional. Select one with a dotted token:

curo test.unit
curo test.functional backend/server

Skip a sublevel from the fan-out with --without:

curo test --without functional

Chaining Commands

Chain additional action tokens onto one invocation with -n/--and:

curo format -n lint -n build -n test.unit

Passing Extra Arguments

Everything after -- goes to the resolved command verbatim, where the manifest chooses to use it:

curo test.functional curo/cli -- -k test_smoke
curo deploy curo/cli -- prod

Deploy and publish targets are deliberately arguments, not configuration: the script owns target validation.

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.

Example

Suppose your project has this structure:

src
├── backend
│   ├── database
│   └── server
└── frontend
    ├── lib
    └── ui

Each bottom-level component (ui, lib, server, database) can be started on its own.

1. Initialize the Project

From your repo root, run:

curo repo init > curo.toml

This prints a starter curo.toml for you to fill in.

2. Top-level curo.toml

Open the root curo.toml. For this layout:

curo_toml_version = "2.1.0"

[about]
name = "node-monorepo"
description = "Full-stack monorepo with Node.js frontend/backend and PostgreSQL database"
code_dir = "src"
components = ["frontend", "backend"]

[dev]
format = "prettier --write ."
lint = "eslint ."
  • code_dir = "src" tells curo all code lives in src/.
  • components lists the first-level folders in src/, in execution order.

3. src/frontend/curo.toml

curo_toml_version = "2.1.0"

[about]
name = "frontend"
components = ["ui", "lib"]

4. src/backend/curo.toml

curo_toml_version = "2.1.0"

[about]
name = "backend"
components = ["server", "database"]

5. Bottom-level curo.tomls

For src/frontend/ui/curo.toml:

curo_toml_version = "2.1.0"

[about]
name = "ui"

[dev]
install = "npm install"
start = "npm run start"

For src/frontend/lib/curo.toml:

curo_toml_version = "2.1.0"

[about]
name = "lib"

[dev]
install = "npm install"
build = "npm run build"

For src/backend/server/curo.toml:

curo_toml_version = "2.1.0"

[about]
name = "server"

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

[dev]
install = "npm install"
start = "npm run start"
test = { cmd = "npm run test", in = "db" }

The server's tests declare they run with the database up: curo test backend/server starts the db runtime, runs the tests inside it, and tears it down afterwards.

For src/backend/database/curo.toml:

curo_toml_version = "2.1.0"

[about]
name = "database"

[dev]
start = "docker compose up -d db"
stop = "docker compose down"

6. Example Usage

  • Install everything:
    curo install
    
  • Format all code:
    curo format
    
  • Lint all code:
    curo lint
    
  • Start just the server:
    curo start backend/server
    
  • Start the UI:
    curo start frontend/ui
    
  • Start only the database:
    curo start backend/database
    
  • Start everything:
    curo start
    

Advanced Usage

curo provides a small set of flags to target, customize and compose workflows. Flags can go anywhere in the invocation.

Selecting the Profile (-p, --profile)

A profile is a lifecycle stage: dev (the default) is the snapshot base, ci adds machine manner, rel adds the release. The chain falls back rel -> ci -> dev, so a stage inherits everything below it unless the manifest overrides it. A pipeline can always say -p ci for its test jobs and -p rel for its delivery jobs:

curo -p ci install
curo -p rel build -n publish curo/cli
curo -p rel deploy curo/cli -- prod   # rel picks the procedure, -- prod picks where it lands

The 1.x spelling -e/--env still parses as a deprecated alias and prints a warning.

Running the Root's Command for a Component (-g, --global)

Use -g/--global to apply the root's definition of an action to a specific component, even if that component defines its own:

curo -g lint frontend/ui

This runs the root's lint command from the frontend/ui directory. Relative paths in the root's command are re-anchored so they still resolve.

If a component should always resolve this way, declare it in the component's manifest instead of remembering the flag: a {{ global }} command makes curo lint frontend/ui do the right thing through ordinary precedence, and curo repo info will show the action under the component. See Configuration.

When an invocation resolves to zero actions, curo says so loudly and suggests the -g invocation if the root defines the action. There is no silent no-op.

Running from Inside a Component (-l, --local)

If you are inside a component's directory, use -l to target it without typing the path:

cd src/frontend/ui
curo -l start

Running All Definitions (-a, --all)

By default, the definition closest to the root wins: if the root defines install, components underneath are not consulted. Use -a to keep resolving through components that also define the action:

curo -a install

Excluding Components (-x, --exclude)

Skip a component and its subtree. The flag takes a slash path, matches exactly, and repeats:

curo build -x frontend/lib -x backend/database

Overriding the Runtime (-r, --runtime)

A command's runtime declaration is its default binding; -r is what's true right now. The reserved name bare forces no runtime at all:

curo test backend/server -r bare      # skip the declared runtime
curo test -r ci-image                 # reproduce CI locally

Every component the invocation reaches must define the named runtime, otherwise the run fails loudly. -r bare is always valid.

Rendering the Plan (--plan)

--plan renders the execution plan without running it: which components resolved, in what order, with which commands.

curo build curo/cli --plan

Machine-Readable Output (--porcelain)

--porcelain replaces the ledger with JSON on stdout, for scripts and agents. It works on action invocations and on the repo commands, and combines with --plan:

curo test.unit --porcelain
curo build curo/cli --plan --porcelain
curo repo info --porcelain

Verbosity (-v)

Repeat -v to raise the ledger's detail level:

curo -vv build

Putting It Together

curo -p ci -x cicd-runtime build -n test.unit -n test.integration
curo test.functional curo/cli -- -k test_smoke

The repo Commands

Everything that is not an action lives behind one reserved word: curo repo. These are repo-structure queries and manifest management. The first bare token after curo is therefore always an action, or repo.

The repo commands honor --porcelain for JSON output.

curo repo info

Displays the project layout: the component tree. -v adds profiles and defined actions, -vv adds versions and descriptions.

curo repo info

public
├── shdx
│   ├── lib
│   └── installer
├── shaddo
│   ├── cli
│   └── installer
└── curo
    ├── cli
    ├── installer
    └── docs

Add -v or more vs for profiles, actions, versions and descriptions:

curo repo info -vv

public [A collection of open source tools and libraries.]
├── dev
│   ├── install
│   ├── format
│   └── lint
├── ci
│   └── install
└── curo [2.1.0 - A command line tool for building projects.]
    └── cli [2.1.0 - The curo command line tool.]
        ├── dev
        │   ├── build
        │   ├── format
        │   ├── test
        │   │   ├── unit
        │   │   ├── integration
        │   │   └── functional
        │   └── deploy
        └── rel
            ├── build
            └── publish

curo repo find

Prints the full path of a component. Use / or no argument for the root. Handy for navigation:

cd $(curo repo find frontend/lib)
cd $(curo repo find /)

curo repo version

Prints the version of a component, with {{ inherit }} resolved. Use / or no argument for the root:

curo repo version curo/cli

(The version of curo itself is curo --version.)

curo repo init

Prints a commented curo.toml template to stdout:

curo repo init > curo.toml

curo repo migrate

Converts 1.x manifests to lean 2.0 manifests, in place. See Migrating from 1.x.

curo repo migrate --dry-run
curo repo migrate

curo repo completion

Prints the shell completion script for fish, bash or zsh. See Installation for setup. Completions cover actions, sublevels, component paths, profiles and runtime names.

Migrating

The loader speaks version 2.1.0 manifests only. Running against an older tree fails with a pointer here. curo repo migrate converts both 1.x and 2.0 trees in place; coming from 1.x there is also a small invocation table below.

Migrating Manifests

From the repository root:

curo repo migrate --dry-run   # report what would change, write nothing
curo repo migrate             # convert in place

migrate walks the component tree, reads each 1.x curo.toml and writes a fresh, lean manifest in its place. Manifests that are already current are left alone. After writing, the whole tree is re-loaded through the strict loader, so a successful migrate means a working tree.

Anything the migrator cannot mechanically decide is flagged for human review, never guessed. The report lists each flag with its location:

  • runtime-slot: a 1.x named runtime slot (the caller-selected -r names). Runtimes are now declared per command; decide whether one is wanted and write it by hand.
  • runtime-lifecycle: a 1.x [runtime.default] start/stop lifecycle that cannot be turned into a runtime automatically.
  • args-usage: a command using {{ args }}. Still supported, but arguments now arrive from -- instead of -D, so confirm the shape.
  • dropped-key: a 1.x key with no current equivalent.
  • profile-split: a [cicd] table (1.x or 2.0). Its release keys (build, publish, deploy) move to [rel] and everything else to [ci]; the flag records where each key landed so you can confirm the call.
  • script-rename: a .curo/actions/cicd.* script. The cicd profile no longer exists, so the file is inert until renamed; the flag suggests the target (rel.* for release actions, ci.* otherwise). migrate rewrites manifests only, never renames files.

Comments and formatting are not preserved: the output is a lean manifest, not an edit.

Invocation Changes

1.x2.0
curo build curo clicuro build curo/cli
curo test unitcuro test.unit
curo -e cicd buildcuro -p rel build
curo -e cicd test unitcuro -p ci test.unit
curo lint -D "--fix" web uicuro lint web/ui -- --fix
curo test --texclude functionalcuro test --without functional
curo build -n "test unit"curo build -n test.unit
curo infocuro repo info
curo find web uicuro repo find web/ui
curo versioncuro repo version
curo init > curo.tomlcuro repo init > curo.toml

The pattern: every addressable thing is one shell token. Sublevels use dots, component paths use slashes, and the quoting disappears.

Notes:

  • -e/--env still parses as a hidden, deprecated alias of -p/--profile and prints a warning. Scripts keep working while you update them.
  • Trailing -- arguments are first-class: they reach every resolved command, so the 1.x rule that -D only worked with a single component is gone.
  • "Environment" is now "profile", and profiles are hard-coded to dev, ci and rel, falling back down the chain. The 1.x cicd fused two of them; pick per invocation: reporting and installing is ci, releasing is rel.
  • Zero resolved actions is now a loud error instead of a silent success.
  • An interrupted or failing step now stops the run and reports honestly; 1.x could print a success footer after a failure.

From 2.0

2.1 split the cicd profile into ci (machine manner: reporting, hookless install) and rel (the release artifact and its delivery), with the linear fallback rel -> ci -> dev. curo repo migrate converts a 2.0 tree the same way it converts 1.x: [cicd] splits by the axis rule with a profile-split flag, and the version line bumps. Two things stay yours:

  • Rename .curo/actions/cicd.* scripts to rel.* or ci.* (discovery keys on the profile name). migrate flags each one it finds with the suggested target; until renamed, the script is silently inert.
  • In pipelines, replace -p cicd with -p ci on test-shaped jobs and -p rel on delivery jobs.

New in 2.x

Worth adopting once migrated:

  • --plan renders the execution plan without running it; with --porcelain it is a machine-readable dry-run.
  • --porcelain emits machine-readable JSON, on actions and on the repo commands. Each planned action names the source_profile its command was found under, so fallback is visible.
  • Declared runtimes: bind a command to a start/exec/stop lifecycle with in = "<name>", and override at the command line with -r, including -r bare.
  • {{ global }}: declare that a component's action runs the root's command, instead of remembering -g.
  • Shell completion: curo repo completion fish|bash|zsh.

Caveats

curo is deliberately constrained. The caps below are the product working as intended, kept small on purpose; the error messages are written to tell you which one you hit.

The Action Vocabulary Is Closed

There are exactly fifteen actions and no way to define more. Sublevels are curated per action, and test (unit, integration, functional) is deliberately the only action that has them. If a workflow does not fit an action, that is a signal to reshape the workflow, not the vocabulary.

Exactly Three Profiles

Profiles are hard-coded: dev, ci and rel, falling back down that chain. A profile means lifecycle stage, nothing else. Deploy targets and environment names (staging, prod) are not profiles; pass them as trailing arguments and let the script own them.

Three Levels, One Level at a Time

Each curo.toml only declares components one level deeper; hierarchies are built by placing a manifest in each component directory. The tree caps at three levels: root, component, subcomponent.

Component Command Precedence

The definition closest to the root wins. If both the root and a component define build, curo build runs only the root's version; use -a/--all to keep resolving through definitions. Component order in components is execution order.

Exclusions Match Exactly

-x/--exclude repeats, takes slash paths, and must exactly match a component path. Pattern matching and globs are not supported.

Local Targeting Needs a Component Directory

-l/--local resolves the component from your current directory. It cannot be used from outside the tree, and discovery stops at a .git boundary.

Runtimes Are Per-Manifest

Runtime names are declared in each component's manifest. A -r override fails loudly on any resolved component that does not define that name; only -r bare is always valid. Runtimes are also torn down when the invocation ends. If you want a database left running between invocations, manage it with start/stop actions instead of a runtime.

The Loader Is Strict

Unknown keys, unknown actions, unknown tokens and wrong shapes in curo.toml are errors, not warnings. A typo cannot silently become a no-op or a corrupted shell command.

Environment Variable Expansion

curo executes commands via the shell (by default bash -c; the shell key overrides it), so environment variables behave as they do in your shell. If a command depends on specific environment setup, make sure your shell provides it.

Cross-Platform Considerations

curo runs your specified shell commands as-is. If you are working in a cross-platform team, ensure your commands are portable or conditionally written.

Dependency Assumptions

curo does not manage or install any system or language dependencies for you. That is for you to set up; curo makes sense of the parts once you have.