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
+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: