Introduction
curo: /ˈkuː.roː/, [ˈkuːroː]
- to arrange, see to, attend to, take care of, look after, ensure, tend to
- to heal, cure
- 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.tomlfiles 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
ciandrelprofiles cover the cases where machine reporting or the release artifact genuinely differ. - Friendly to Automation:
--planrenders what would run without running it, and--porcelainemits 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 ascuro/cli. No path means the whole tree from the root.[-- args...]: everything after--is passed through to the underlying command untouched.flagscan go anywhere; see Advanced Usage.
The Actions
The action vocabulary is fixed. Every action has a short alias.
| Action | Alias | Meaning |
|---|---|---|
install | i | Install the environment |
build | b | Build some code |
lint | l | Check for linting and typing code |
format | f | Format some code |
generate | g | Generate code from a template or specification |
clean | c | Clean up the environment |
start | st | Start an application detached |
run | rn | Execute and tail an application |
stop | sp | Stop a detached application |
restart | r | Quickly restart an application |
status | ss | Check whether any applications are running |
test | t | Test some code |
log | lg | Tail the logs of a running application |
publish | p | Publish an artifact |
deploy | d | Deploy the artifact |
There are no user-defined actions. If a component needs a command, it lives under one of these names.
Common Workflow
-
Install project dependencies
curo install -
Build the project
curo build -
Run tests
curo test -
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. Thiscurospeaks"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 bycuro 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:devplus machine manner. Reporting formats, hookless install;cichanges how commands report, never what they build.rel:ciplus 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'sabout.name.{{ version }}: the component'sabout.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 insrc/.componentslists the first-level folders insrc/, 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-rnames). 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. Thecicdprofile no longer exists, so the file is inert until renamed; the flag suggests the target (rel.*for release actions,ci.*otherwise).migraterewrites manifests only, never renames files.
Comments and formatting are not preserved: the output is a lean manifest, not an edit.
Invocation Changes
| 1.x | 2.0 |
|---|---|
curo build curo cli | curo build curo/cli |
curo test unit | curo test.unit |
curo -e cicd build | curo -p rel build |
curo -e cicd test unit | curo -p ci test.unit |
curo lint -D "--fix" web ui | curo lint web/ui -- --fix |
curo test --texclude functional | curo test --without functional |
curo build -n "test unit" | curo build -n test.unit |
curo info | curo repo info |
curo find web ui | curo repo find web/ui |
curo version | curo repo version |
curo init > curo.toml | curo 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/--envstill parses as a hidden, deprecated alias of-p/--profileand 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-Donly worked with a single component is gone. - "Environment" is now "profile", and profiles are hard-coded to
dev,ciandrel, falling back down the chain. The 1.xcicdfused two of them; pick per invocation: reporting and installing isci, releasing isrel. - 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 torel.*orci.*(discovery keys on the profile name).migrateflags each one it finds with the suggested target; until renamed, the script is silently inert. - In pipelines, replace
-p cicdwith-p cion test-shaped jobs and-p relon delivery jobs.
New in 2.x
Worth adopting once migrated:
--planrenders the execution plan without running it; with--porcelainit is a machine-readable dry-run.--porcelainemits machine-readable JSON, on actions and on therepocommands. Each planned action names thesource_profileits 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.