v2.0.0next
repos.yaml

How connect proposes commands

What kraft repo connect reads in a repo, where each proposed setup and test command comes from, and what it does when it finds none.

kraft repo connect proposes a setup_command and a test_command (and test_scopes for a repo with more than one project in it) by reading the repo's own files. It prints each command with the file it came from and lists what else it found.

The result is a proposal for you to check. Nothing at run time guesses: a work item runs only what repos.yaml declares. To teach connect your own conventions, see detectors.yaml.

If connect proposed nothing or the wrong thing, start with When connect finds no command and Choosing something else. The sections after them say how a proposal is made.

When connect finds no command

Connect says when it found no command. With no test_command it saves the repo disabled (see enabled). kraft admin doctor reports any connected repo still missing a setup_command. Either:

  • Set setup_command yourself, or "" (the No setup needed checkbox in Settings › Repos) if the repo needs no preparation, and set test_command, then enabled: true.
  • Or fix the cause, such as committing the missing lockfile, and run kraft repo connect again: on a connected repo it fills in only what the entry left undecided, and enables a repo that was disabled for want of a test command.

A repo that has no tests at all is the third case: connect it with --no-tests (see Choosing something else). A setup it found still has to be confirmed or replaced.

Choosing something else

The proposal is a starting point. The kraft repo connect flags (--test-command, --setup-command, --no-tests, --verify and --timeout) replace or check it. What they do to a proposal, beyond what that page says:

  • --test-command replaces the root's test command. Nested scopes are still probed.
  • --setup-command is the repo's whole setup_command, nested directories included, since that is the one field repos.yaml has.
  • With --no-tests, verify passes, saying so. Nested and area scopes still run, and a change outside them selects every scope, as it always does.

In a terminal, connect lists the root's candidates and asks which to use.

Verify a proposal

--verify fails when a command leaves files every work item would commit, such as a lockfile an unlocked install writes, or an unignored build/. It also fails when a command does not finish in time, usually a test runner in watch mode.

Each test runs with the environment a work item gives it, so the __pycache__/ a Python test would otherwise write is not reported.

What verify does with files a command writes:

  • A virtualenv or node_modules a setup installs does not fail verify, since Kraft's own commits leave it out. If the repo does not ignore it, verify still names it: the agent commits on its own too, and its git add -A would take it. Add it to .gitignore.
  • A uv.lock the setup command writes, as uv sync does in a repo that commits none, does not fail verify either (an entry connected before 2.0 can still run it). Kraft's commits leave that file out, the item's events name it (sweep_left_out), and verify names it too.
  • A uv.lock the agent writes is its work and is committed, and so is an edit the agent makes to the setup's. One a test command writes (uv run) is committed as well, so verify fails on it. Commit one (uv lock) so every work item installs the same versions.
  • A Cargo.lock that cargo fetch writes in a crate that commits none is treated the same way, with cargo generate-lockfile as the advice.

What is read

The probe reads the commit a work item's worktree is cut from, not your working copy:

  • origin's default branch when the clone has it (origin/HEAD, else origin/main)
  • otherwise the checkout's HEAD

An untracked file, an uncommitted edit, or a commit you have not pushed yet changes nothing. Connect says which commit it read.

A repo with no commit yet is refused: a work item's branch starts from a commit, so the proposal would be read from files the branch does not have. Commit its files, then connect it. (The probe alone, POST /repos/probe, which only inspects, reads such a repo from its working copy: its own regular files, nothing gitignored, no symlinks.)

A file is read up to 4 MB, and a Git LFS file reads as its pointer. A repo that git cannot list within two minutes fails the probe rather than reading as empty; connect it with --test-command and --setup-command instead. If you give both, a repo the probe cannot read still connects with them, and connect says why the probe failed.

Where a proposal comes from

Evidence is ranked. The first kind found wins, and everything else it found is listed as an alternative:

RankEvidenceExamples
1A task runner the repo wrote. A test (or tests, check) task, and a setup (or bootstrap, deps) task.justfile recipes, Makefile targets, Taskfile.yml tasks, mise.toml tasks, executable script/test and script/bootstrap, bin/test and bin/setup
2A toolchain's default, picked by its lockfile.pnpm-lock.yaml gives pnpm, poetry.lock gives poetry, gradlew gives ./gradlew
3What CI runs. Test commands only, and only where neither of the above has one..github/workflows/*.yml (with each step's working-directory), .gitlab-ci.yml, CircleCI, Azure Pipelines, Bitbucket, Buildkite, Drone, Woodpecker, Travis

CI ranks last because a CI job is often one slice of the suite: one matrix entry, one module, a type check named typecheck:tests. The repo's own test task or toolchain default runs all of it.

CI rules

Within CI:

  • A command that a runner or toolchain also proposes is shown with "CI runs it too". Between two candidates of the same rank, the one CI runs wins.
  • A line that runs a bare tool (pytest -x, jest) ranks below every other candidate. CI installed that tool on its own PATH, which a worktree does not have.
  • The repo's test workflows are read before its e2e, Playwright, Cypress, nightly, release, deploy and docs ones.
  • A line that only means something inside its script is dropped. That covers a variable, a pipe or redirect, a case arm, echo, an OS package install, or a global tool install.
  • A tool that also runs other things (npm, pnpm, yarn, bun, deno, cargo, go, dotnet) counts only on its test verb (npm test, npm run test:unit, go test). No runner counts when the line has it run something else (exec, dlx, --call, --shell-mode, --config, -exec).
  • test as part of a task name (typecheck:tests) or after Gradle's -x, which excludes the tests, does not make a line a test command. Nor does it in a tool's own install (mix deps.get --only test, uv sync --group tests).
  • A step's working-directory is held to the same rules as any other directory: by default at most two levels down (max_depth in detectors.yaml changes it), and under no non-project name such as test.

A CI line that hands a script to a shell

Connect lists such a line with the CI candidates, marked as a script, and never chooses it. What runs is in the quoted text, which Kraft does not read. A line is one of these when it is:

  • a shell or interpreter run with -c: bash -c "…", python -c "…"
  • an interpreter given a script by its own script flag, whatever the script holds: perl -e '…', ruby -I lib -e '…', node -e '…', php -r '…'. python -E -m pytest is not one.
  • any line whose quoted text holds shell syntax (|, ;, &, <, >, parentheses or braces): env -S '…', nix-shell --run "…", pwsh -Command "…", awk 'BEGIN { system(…) }'

The exception is a known test runner reading that text as its own argument, run with no shell: pytest -k "not (a or b)", go test -run 'Test(Foo|Bar)', jest --testPathPattern "(unit|int)". It stays a candidate, also behind uv run, poetry run, python -m, npx or xvfb-run and their options.

Evidence that is listed and never chosen

  • CI setup lines. CI installs global tools a worktree must not.
  • A dev container's create commands. They are written for a container, and often install into its global interpreter. One that provisions the container itself (sudo, apt-get) is not listed at all.
  • A CI line that hands a script to a shell. See the section above.

Evidence a toolchain needs

A toolchain (the shipped table lists them) needs evidence for its test command, as well as its marker file:

  • A package.json counts only with a real test script. npm's "no test specified" placeholder does not count.
  • pytest is proposed only when something names it: pytest.ini, a conftest.py, a [tool.pytest] table, a dependency, or a lockfile entry. A plugin like flake8-pytest-style does not count.
  • A pyproject.toml holding only tool settings (ruff, black) does not make a repo a Python project.
  • go test ./... is proposed only when a _test.go file is there or below. With none, it compiles every package and passes with "no test files", which tests nothing.

How a command is run

A task runner's test task that runs pytest, python or coverage bare, in a directory a Python lockfile locks, is run through that lockfile's tool: uv run ./scripts/test.sh, poetry run make test. Bare, it would run whatever Python a worker's PATH has, without the project's dependencies.

A toolchain's install takes the flags CI gives the same command in the same directory: a CI step uv sync --extra all --group tests makes the proposal that, not uv sync. Only options and their values are taken.

When toolchains of more than one family have a test in one directory (tauri's pnpm and Cargo, phoenix's npm and mix), its test command runs each of them, in the shipped table's order (Python first): sh -c 'pnpm test && cargo test --workspace'. One family's tests passing says nothing of the other's. Its setup is then each of those toolchains' installs. A task runner's test command (make test) gets every toolchain's install, since which ones it needs is not known.

A toolchain whose setup makes a virtualenv (a requirements.txt project's .venv) has a task runner's or CI's test command there run inside it. venv says how.

A Python project with no lockfile

One project gets no proposal at all: a pyproject.toml declaring a project with no lockfile beside it (uv.lock, poetry.lock, pdm.lock, a Pipfile). uv sync and uv run would each write a uv.lock into every work item's worktree, for the worker to commit.

  • Its directory proposes no test and no setup. Kraft does not fall back to a go.mod or package.json beside it either.
  • Two things still stand there: a lockfile install (npm ci beside a package-lock.json) and a task runner's own test task, which comes first.
  • At the root, it means no test command for the whole repo.
  • Below the root, the directory gets no test command either. A change there would otherwise pass on the other scopes' tests: say backend/ has such a project and frontend/ has a package.json. A change to backend/ matches no scope, and a change that matches none runs every scope, so a frontend/** scope alone would pass it on npm test with the Python tests never run.

The exception is a root command that already runs those tests:

  • a command you give with --test-command
  • the root task runner's test recipe
  • a lockfile install of the same kind at the root (a uv workspace's uv.lock)

The directory then gets no scope of its own, and the root scope covers it.

Connect says which directory stopped the proposal, and what to do: a Poetry or PDM project is told to commit its own lock (poetry lock, pdm lock), and any other to set its test command, or commit a uv.lock. Once the lockfile is committed, run kraft repo connect again: it fills in what the first connect left undecided.

Proposals that need something from you

Some proposals need something only you can supply. Check these with --verify:

  • An Android build needs the SDK. Pass ANDROID_HOME through with the repo's env_passthrough.
  • A Rails app's bin/setup --skip-server prepares the database, so the database must be running.
  • A test target that starts docker needs docker where the work item runs.

Directories and scopes

Each directory up to two levels below the root that has its own tests becomes a test scope of its own. Two levels is the default: max_depth in detectors.yaml changes it. Some directories are skipped:

  • hidden directories
  • the usual non-project names, in any case: examples, benchmarks, support, docs, fixtures, vendor, deps, tests, build and the like (ignore_dirs in detectors.yaml adds names)
  • a submodule's contents, because a submodule is a repo of its own

A scope's command runs from the worktree root without a shell, so a nested scope's command is written sh -c 'cd web && npm test'. Its setup joins the repo's setup_command in a subshell, for example uv sync && (cd web && npm ci).

Two things prepare a nested project from above:

  • A workspace root. A pnpm or npm workspace, a Cargo [workspace], a uv workspace, a multi-module Maven or Gradle build, a .NET solution, or an Elixir umbrella installs its members. When the root has a test command, its members are not scopes of their own. A member with a lockfile of its own installs on its own.
  • The root's own setup task (just setup, make setup), for each directory that task, or a task it depends on, works in: cd web, --prefix web, web/.... Comments and other recipes do not count.

A root that is a project itself (a Gemfile, a package.json, a Makefile) with no test command found, while directories below it have theirs, gets no proposal at all. A change to the root's own code would select only the nested scopes, and pass on them. Connect says which file made the root a project.

Pass --test-command for the root, or --no-tests if the root really has none; the nested scopes are then proposed.

A proposed command whose program this machine's PATH does not have (a deno scope on a machine with no deno) is named at connect, with its directory: a work item would fail on it until it is installed. The scope stays, since its tests are real; connect with your own --test-command and --setup-command to leave it out.

If a directory has a test command and nothing prepares it, setup_command is left undeclared. kraft repo connect names the directory. The repo's first work item then stops until you declare a setup command, which can be "" for none.

Limits on what the probe reads

A repository's files are whatever someone committed, so the probe treats them as hostile. These limits keep a file from stalling the Kraft server or leaking its secrets:

LimitValue
TimeThe probe runs in a process of its own, killed after two minutes.
EnvironmentThat process gets none of the server's environment beyond PATH, HOME and the locale, so none of its API keys or tokens. It never imports from the directory the server was started in.
One CI file or TaskfileUp to 50,000 YAML nodes and script lines, counted before the YAML is built, with every use of an alias counting again. One past the limit is skipped.
All CI files and Taskfiles togetherUp to 200,000 nodes and script lines. One past the limit is skipped.
All files in one probe64 MB.
A file that cannot be parsed, or is nested deeper than the parser goesNot evidence. The rest of the repo still is.
A CI or dev container command with a control character in it (an ANSI escape, a bidi override)Not a candidate: it could show you one command while another runs.
The repo's own git settingsThey never run a program: core.fsmonitor is turned off for the probe's git calls.
  • detectors.yaml: the detectors.yaml keys that teach connect your own conventions, and the toolchains Kraft ships.
  • repos.yaml: the setup_command, test_command and test_scopes fields a proposal fills in.
  • Repo verbs: the kraft repo connect flags.
Copyright © 2026