install: detect the test command instead of asking for it

The third setup question wanted an answer about a repo the person may
have just cloned, thirty seconds in, before anything had explained why
the board needed one. The file that names a project's ecosystem usually
names its runner too, so it is read off the project: package.json → npm
test, Cargo.toml → cargo test, go.mod → go test ./..., a
pyproject/setup.py/tests/ → python3 -m unittest.

Nothing recognisable writes BOARD_AGENT_COMMANDS empty rather than the
example's Python default, which would be quietly wrong in most repos —
and a wrong prefix denies an agent exactly the way an empty one does, so
absent beats wrong here.

Two details the first attempt got wrong, both now covered:

- Detection runs *before* the questions. Placed after them, a Ctrl-D
  part-way through skipped it and left the example's default.
- "Keep what the project already said" reads the existing .env, not the
  example-seeded defaults dict — which already carries the example's
  answer, so every first run looked like it had one and detection never
  ran.

The landing page's terminal, README.md and AGENTS.md follow: two
questions now, and AGENTS.md documents the detection table.
This commit is contained in:
istos
2026-07-31 16:47:30 +02:00
parent 866c4370c6
commit d3a43ef1ae
6 changed files with 133 additions and 46 deletions
+16 -4
View File
@@ -155,10 +155,22 @@ the two settings that change what bench *is* — claim-on-move and syncing
through origin/main — stay invisible to anyone who has not read
`core/.env.example`. So the first run asks, and writes the file: **solo or
team** (team turns `BOARD_COMMIT_MOVES` and `BOARD_SYNC` on together, and
the question says what that costs), **which agent adapter** (enumerated
from the adapter directories, so a project's own `local/adapters/` entry
is offered), and **what command runs this project's tests**
(`BOARD_AGENT_COMMANDS` — the one a headless agent cannot work around).
the question says what that costs) and **which agent adapter**
(enumerated from the adapter directories, so a project's own
`local/adapters/` entry is offered).
It does not ask what runs the project's tests. That was a third question
once, and it wanted an answer about a repo the person may have just
cloned, thirty seconds in and before anything had explained why the
board needed one. It is detected instead — `package.json``npm test`,
`Cargo.toml``cargo test`, `go.mod``go test ./...`, a
`pyproject.toml`/`setup.py`/`tests/``python3 -m unittest` — and a
project matching none of them gets `BOARD_AGENT_COMMANDS` empty, which
is honest: an agent then runs no project commands until someone fills it
in. A wrong guess costs nothing an empty value would not, since a prefix
that matches nothing denies exactly the same way. An existing value is
never overwritten, so `--setup` cannot undo a hand-edit.
Bare Enter takes the default, Ctrl-D skips the rest, and what lands is
`core/.env.example` with the answers substituted into their lines: every
other key, every comment, so the written file is where the project reads
+7 -4
View File
@@ -21,10 +21,13 @@ mkdir .task-manager && curl -L \
No token, no clone: releases are curated artifacts that never contained
bench's own cards or settings, so the board starts empty by construction.
The first run asks three questions it cannot answer for you — solo or
team, which agent adapter, what command runs your tests — and writes
`manager/local/.env` from the documented example, so every other setting
is discoverable in your own copy. Bare Enter takes the default
The first run asks the two questions it cannot answer for you — solo or
team, and which agent adapter — and writes `manager/local/.env` from the
documented example, so every other setting is discoverable in your own
copy. What runs your tests is read off the project rather than asked
(`package.json``npm test`, and so on); nothing recognisable leaves
`BOARD_AGENT_COMMANDS` empty, and that is the key to set before an agent
can run them. Bare Enter takes the default
throughout; with no terminal (CI, a script) it asks nothing and
`install.py --setup` asks later.
+57 -17
View File
@@ -124,9 +124,10 @@ def first_boot_clean(dry_run: bool) -> None:
# ── First-run settings ────────────────────────────────────────────────
#
# Everything not asked about is written at its documented default, so the
# answers are only the ones no default can be right about: how this
# project works (solo or team), which agent runs its headless jobs, and
# what command runs its tests.
# answers are only the ones no default can be right about and nothing
# can be read off the project: how this project works (solo or team)
# and which agent runs its headless jobs. The test command used to be a
# third question; it is detected instead — see TEST_COMMANDS.
ENV_EXAMPLE = CORE / ".env.example"
ENV_FILE = LOCAL / ".env"
@@ -137,10 +138,33 @@ TEAM_NOTE = """\
merges, and a local main that only ever advances through the board.
Solo today's default — does none of it."""
COMMANDS_NOTE = """\
Headless agents may only run the command prefixes named here, so a test
runner missing from the list is a test the work agent cannot run.
Comma-separate several."""
# What runs this project's tests, read off the project rather than asked.
# It was a question once, and it was the wrong one to put to someone
# thirty seconds into their first run: it wants an answer about a project
# they may have just cloned, before anything has explained why the board
# needs it. The file that names a project's ecosystem usually names its
# test runner too, so the first match wins and no match writes nothing.
#
# A wrong guess costs nothing it did not already cost: the prefix simply
# never matches, and the agent is denied exactly as it would be with the
# key empty. What it must never do is guess something *broader* than the
# truth — every entry here is one runner, not a shell.
TEST_COMMANDS = [
("package.json", "npm test"),
("Cargo.toml", "cargo test"),
("go.mod", "go test ./..."),
("pyproject.toml", "python3 -m unittest"),
("setup.py", "python3 -m unittest"),
("tests", "python3 -m unittest"),
]
def detect_test_command(root: Path) -> str:
"""The project's test runner, or "" when nothing here names one."""
for marker, command in TEST_COMMANDS:
if (root / marker).exists():
return command
return ""
class _Skipped(Exception):
@@ -217,9 +241,25 @@ def _ask(question: str, default: str, note: str = "") -> str:
return answer or default
def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None:
def ask_questions(current: dict[str, str], answers: dict[str, str],
held: dict[str, str] | None = None) -> None:
"""Fill `answers` in place — in place because a Ctrl-D part-way through
keeps what was already answered."""
keeps what was already answered.
`current` is the example's values under the existing file's, which is
what a question should offer as its default. `held` is only what this
project itself has said empty on a first run because "keep what is
already there" must not mean "keep the example's default".
"""
held = held or {}
# Detected, not asked — and settled before the first question, so a
# Ctrl-D part-way through still leaves the project's own runner rather
# than the example's Python one. A value this project has already set
# wins: --setup must not undo a hand-edit. The example's default is
# not such a value, which is why this reads `held` and not `current`.
answers["BOARD_AGENT_COMMANDS"] = (
held.get("BOARD_AGENT_COMMANDS") or detect_test_command(PROJECT))
team = env_on(current.get("BOARD_SYNC", "")) or env_on(
current.get("BOARD_COMMIT_MOVES", ""))
while True:
@@ -246,10 +286,6 @@ def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None:
print(f" no such adapter here — one of: {', '.join(allowed)}.")
answers["BOARD_AGENT_ADAPTER"] = reply
answers["BOARD_AGENT_COMMANDS"] = _ask(
"what command runs this project's tests?",
current.get("BOARD_AGENT_COMMANDS", "python3 -m unittest"),
note=COMMANDS_NOTE)
def setup(dry_run: bool, forced: bool) -> None:
@@ -261,9 +297,12 @@ def setup(dry_run: bool, forced: bool) -> None:
return
verb = "rewrite" if exists else "write"
if dry_run:
print(f"would ask: solo or team, which agent adapter, the test "
f"command — and {verb} {_rel(ENV_FILE)} from "
f"{_rel(ENV_EXAMPLE)}.\nDry run — nothing written.\n")
found = detect_test_command(PROJECT)
print(f"would ask: solo or team, which agent adapter — and "
f"{verb} {_rel(ENV_FILE)} from {_rel(ENV_EXAMPLE)}, with "
f"BOARD_AGENT_COMMANDS="
f"{found or '(nothing detected)'}.\n"
f"Dry run — nothing written.\n")
return
if not sys.stdin.isatty():
if exists:
@@ -290,7 +329,8 @@ def setup(dry_run: bool, forced: bool) -> None:
answers: dict[str, str] = {}
try:
ask_questions(current, answers)
ask_questions(current, answers,
held=env_values(base) if exists else {})
except _Skipped:
print(" skipped — the rest stay at their documented defaults.")
except KeyboardInterrupt:
-1
View File
@@ -91,7 +91,6 @@ $nav
solo or team? <span class="t-accent">[solo]</span>:
which agent adapter? <span class="t-accent">[claude]</span>:
what command runs this project's tests? <span class="t-accent">[python3 -m unittest]</span>:
<span class="t-dim"></span>
+53 -17
View File
@@ -204,17 +204,47 @@ class FirstRunSettings(unittest.TestCase):
self.env_file.read_text(encoding="utf-8"))
def test_bare_enter_everywhere_writes_the_shipped_defaults(self):
"""Every question defaulted → the file is the example verbatim, so
the board behaves exactly as it does with no .env at all."""
out = run_install_tty(self.tm, ["", "", ""])
"""Every question defaulted → the file is the example, save the one
key that is read off the project rather than asked about. A scratch
host names no test runner, so that key lands empty."""
out = run_install_tty(self.tm, ["", ""])
self.assertIn("solo or team?", out)
self.assertIn("which agent adapter?", out)
self.assertIn("what command runs this project's tests?", out)
self.assertEqual(self.env_file.read_text(encoding="utf-8"),
self.example)
self.assertEqual(
self.env_file.read_text(encoding="utf-8"),
self.example.replace("BOARD_AGENT_COMMANDS=python3 -m unittest",
"BOARD_AGENT_COMMANDS="))
def test_the_test_command_is_never_asked_for(self):
"""It was a third question once. It wanted an answer about a repo
the person may have just cloned, before anything had explained why
the board needed one."""
out = run_install_tty(self.tm, ["", ""])
self.assertNotIn("runs this project's tests", out)
def test_the_test_command_is_read_off_the_project(self):
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_a_project_naming_no_runner_gets_an_empty_allowlist(self):
"""Empty is honest: the agent runs no project commands until
someone fills it in. The example's Python default would be quietly
wrong in most repos, and wrong is worse than absent here."""
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "")
def test_the_first_marker_wins(self):
"""A repo with both a package.json and a tests/ directory is a JS
project with tests, not a Python one."""
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
(self.tm.parent / "tests").mkdir(exist_ok=True)
run_install_tty(self.tm, ["", ""])
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_answers_are_substituted_into_the_whole_example(self):
out = run_install_tty(self.tm, ["team", "claude", "npm test"])
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
out = run_install_tty(self.tm, ["team", "claude"])
written = self.env_file.read_text(encoding="utf-8")
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "1")
self.assertEqual(self.values()["BOARD_SYNC"], "1")
@@ -229,21 +259,25 @@ class FirstRunSettings(unittest.TestCase):
self.assertIn("Wrote .task-manager/manager/local/.env", out)
def test_solo_leaves_both_team_settings_empty(self):
run_install_tty(self.tm, ["solo", "", ""])
run_install_tty(self.tm, ["solo", ""])
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "")
self.assertEqual(self.values()["BOARD_SYNC"], "")
def test_an_invalid_answer_is_asked_again(self):
out = run_install_tty(self.tm, ["both", "team", "", ""])
out = run_install_tty(self.tm, ["both", "team", ""])
self.assertIn("answer solo or team.", out)
self.assertEqual(self.values()["BOARD_SYNC"], "1")
def test_ctrl_d_skips_the_rest_and_writes_the_defaults(self):
"""The detected command is settled before the first question, so a
Ctrl-D part-way through still leaves the project's own runner —
not the example's Python one, which is what a later detection
would have been skipped past."""
(self.tm.parent / "package.json").write_text("{}\n", encoding="utf-8")
out = run_install_tty(self.tm, ["team", CTRL_D])
self.assertIn("skipped", out)
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"],
"python3 -m unittest")
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "npm test")
def test_the_adapter_question_enumerates_the_directories(self):
"""Adapters are listed from disk, so a project's own local one is
@@ -255,12 +289,12 @@ class FirstRunSettings(unittest.TestCase):
mine = self.tm / "manager" / "local" / "adapters" / "mine"
mine.mkdir(parents=True)
(mine / "run").write_text("#!/bin/sh\n", encoding="utf-8")
out = run_install_tty(self.tm, ["", "mine", ""])
out = run_install_tty(self.tm, ["", "mine"])
self.assertIn("here: claude, opencode, mine.", out)
self.assertEqual(self.values()["BOARD_AGENT_ADAPTER"], "mine")
def test_an_existing_env_is_never_touched_and_the_run_stays_quiet(self):
run_install_tty(self.tm, ["team", "", ""])
run_install_tty(self.tm, ["team", ""])
written = self.env_file.read_text(encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@@ -297,13 +331,15 @@ class FirstRunSettings(unittest.TestCase):
.replace("BOARD_AGENT_COMMANDS=python3 -m unittest",
"BOARD_AGENT_COMMANDS=make test"),
encoding="utf-8")
out = run_install_tty(self.tm, ["", "", ""], "--setup")
out = run_install_tty(self.tm, ["", ""], "--setup")
self.assertIn("[team]", out) # the current file's answer…
self.assertIn("[make test]", out) # …offered as the default
self.assertEqual(self.values()["BOARD_SYNC"], "1")
self.assertEqual(self.values()["BOARD_PORT"], "26099")
# …and the hand-edited command survives, detection notwithstanding:
# an existing answer is never second-guessed by a marker file.
self.assertEqual(self.values()["BOARD_AGENT_COMMANDS"], "make test")
out = run_install_tty(self.tm, ["solo", "", ""], "--setup")
out = run_install_tty(self.tm, ["solo", ""], "--setup")
self.assertEqual(self.values()["BOARD_SYNC"], "")
self.assertEqual(self.values()["BOARD_COMMIT_MOVES"], "")
self.assertEqual(self.values()["BOARD_PORT"], "26099")
@@ -320,7 +356,7 @@ class FirstRunSettings(unittest.TestCase):
def test_first_boot_both_clears_the_cards_and_writes_the_env(self):
"""The order is load-bearing: .env is one of the two things the
first-boot guard reads, so writing it early would skip the clean."""
out = run_install_tty(self.tm, ["", "", ""])
out = run_install_tty(self.tm, ["", ""])
self.assertIn("removed tasks/backlog/00-shipped-card.md", out)
self.assertEqual(shipped_files(self.tm), [])
self.assertTrue(self.env_file.is_file())
-3
View File
@@ -48,9 +48,6 @@ CONFIG = REPO / "manager" / "core" / "config.py"
TRANSCRIPT = [
("solo or team?", "solo or team?", INSTALL),
("which agent adapter?", "which agent adapter?", INSTALL),
("what command runs this project's tests?",
"what command runs this project's tests?", INSTALL),
("python3 -m unittest", "python3 -m unittest", INSTALL),
("Task board for", "Task board for", BOARD),
("http://127.0.0.1:", "http://127.0.0.1:", BOARD),
("26071", "26071", CONFIG),