Files
bench/install.py
T
istosandClaude Opus 5 635205486b A first run writes local/.env, asking what it cannot guess
A project could run bench for months without a manager/local/.env:
everything fell back to core/.env.example, so the two settings that
change what bench is — claim-on-move and syncing through origin/main —
were invisible to anyone who had not read that file.

install.py now writes it on a first run. It asks three questions no
default can be right about (solo or team, which agent adapter, the
project's test command) and writes core/.env.example with the answers
substituted into their lines, comments and all keys intact — so the
written file is where the project reads what else it can change.

- Runs after first_boot_clean: .env is one of the two things the
  first-boot guard reads, so writing it earlier would skip the clean.
- Never asks without a terminal on stdin. install.py sits on the path
  of start.sh, update.sh and every hook, so no TTY prints one line and
  carries on rather than blocking a board start on an invisible prompt.
  --dry-run reports the questions and writes nothing.
- An existing .env is never touched; --setup is the only way back to
  the questions, pre-filling from the current file and rewriting it in
  place, so start.sh's fallback BOARD_PORT line survives.
- Bare Enter takes every default (the result is the example verbatim,
  i.e. today's behaviour exactly); Ctrl-D skips the rest.

start.sh needed no change — it already calls install.py before the port
dance, which is the right order. Tests drive the interactive runs over a
real pty and the non-interactive ones with /dev/null on stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:55:57 +02:00

339 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Wire the task manager into the project it sits in.
python3 .task-manager/install.py # apply (idempotent)
python3 .task-manager/install.py --dry-run # report only, change nothing
python3 .task-manager/install.py --setup # ask the settings questions again
Vendor-specific wiring belongs to the configured agent adapter: this script
resolves the adapter (BOARD_AGENT_ADAPTER in manager/local/.env, default
"claude"; local/adapters/ overrides core/adapters/) and runs its `wire`
executable against the project root. Safe to run any time — after dropping
.task-manager/ into a new repo, and after every update.sh.
The distribution repo tracks its own development on its own board, so a
fresh clone arrives carrying those cards. The very first run in a host
project — vendored, before manager/local/ has ever been populated —
clears the stage directories, tasks/archive/, plans/ and reference/
(keeping task-template.md and .gitkeep files, printing every removal) and
then stamps manager/local/state/ so the guard is false on every later run.
A project with no manager/local/.env then gets one written, because the
settings that change what bench *is* — claim-on-move and syncing through
origin/main — are otherwise invisible to anyone who has not read
manager/core/.env.example. Setup asks the few questions it cannot answer
for the project and writes that example file with the answers substituted
in, so the rest of the settings are discoverable by opening the result.
It runs after first_boot_clean (writing .env early would flip the
first-boot guard and leave the distribution's cards in a host project), it
never asks without a terminal on stdin — install.py sits on the path of
start.sh, update.sh and anything automated — and an existing .env is never
touched except by an explicit --setup.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
TM = Path(__file__).resolve().parent
LOCAL = TM / "manager" / "local"
CORE = TM / "manager" / "core"
STAGE_DIRS = ["backlog", "to-do", "in-progress", "review", "done"]
KEEP = {".gitkeep", "task-template.md"}
def _project_root() -> Path:
"""The host project's root: the git toplevel seen from the manager's
directory (same resolution as config._repo_root). Vendored installs
drop .task-manager/.git at clone time, so this finds the host repo;
when the manager IS the repo (self-hosted), it finds that repo itself.
No git → fall back to the vendored-layout assumption, the parent."""
try:
out = subprocess.check_output(
["git", "-C", str(TM), "rev-parse", "--show-toplevel"],
text=True, stderr=subprocess.DEVNULL,
).strip()
if out:
return Path(out)
except (subprocess.CalledProcessError, OSError):
pass
return TM.parent
PROJECT = _project_root()
def _content_dirs(tm: Path) -> list[Path]:
tasks = tm / "tasks"
return ([tasks / stage for stage in STAGE_DIRS]
+ [tasks / "archive", tm / "plans", tm / "reference"])
def first_boot(tm: Path, project: Path) -> bool:
"""True only on a vendored install's very first run — the one moment
anything in the stage directories can only be the distribution's own.
False in every other situation:
- self-hosted (the manager IS the repo): tasks/ is that repo's own
history, never distribution residue — including a fresh dev clone;
- already wired (local/.env or local/state/ exists): anything in the
stage directories can only be the host project's own work."""
if project.resolve() == tm.resolve():
return False
local = tm / "manager" / "local"
return not (local / ".env").exists() and not (local / "state").exists()
def first_boot_leftovers(tm: Path) -> list[Path]:
"""The distribution's shipped cards, plans and reference documents —
the paths a first boot must clear."""
return [child
for d in _content_dirs(tm) if d.is_dir()
for child in sorted(d.iterdir()) if child.name not in KEEP]
def first_boot_clean(dry_run: bool) -> None:
"""First boot only: remove the distribution's shipped content and stamp
local/state/ so this never runs again — even if the adapter wire fails
(a host without .claude/ still gets the board via start.sh) or the host
creates cards before the next run. Off first boot nothing is touched,
not even the stamp."""
if not first_boot(TM, PROJECT):
return
leftovers = first_boot_leftovers(TM)
if leftovers:
print("first boot — clearing the distribution's own cards:")
verb = "would remove" if dry_run else "removed"
for path in leftovers:
print(f" {verb} {path.relative_to(TM)}")
if not dry_run:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
print()
if not dry_run:
(LOCAL / "state").mkdir(parents=True, exist_ok=True)
# ── 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.
ENV_EXAMPLE = CORE / ".env.example"
ENV_FILE = LOCAL / ".env"
TEAM_NOTE = """\
Team mode: moves claim and commit themselves, and boards converge
through origin/main. It wants a shared origin, merge rights for whoever
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."""
class _Skipped(Exception):
"""Ctrl-D: stop asking. Answers already given stand, the rest of the
file stays at its documented defaults."""
def _rel(path: Path) -> str:
"""A path as a reader would type it, relative to the project root."""
try:
return str(path.relative_to(PROJECT))
except ValueError:
return str(path)
def env_values(text: str) -> dict[str, str]:
"""KEY=VALUE lines, # comments, optional quotes — config._load_env's
parser, minus the process environment (this is about the file)."""
values: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("'\"")
return values
def env_on(value: str) -> bool:
"""config.flag's rule: anything but empty/0/false/no/off is on."""
return value.strip().lower() not in ("", "0", "false", "no", "off")
def substitute(base: str, answers: dict[str, str]) -> str:
"""`base` with the answered keys rewritten in place — every comment and
every other key intact, which is the point: the written file is where
the project reads what else it can change. A key the base does not
mention is appended rather than lost."""
out, placed = [], set()
for line in base.splitlines():
stripped = line.strip()
key = ("" if stripped.startswith("#") or "=" not in stripped
else stripped.partition("=")[0].strip())
if key in answers:
out.append(f"{key}={answers[key]}")
placed.add(key)
else:
out.append(line)
for key in [k for k in answers if k not in placed]:
out.append(f"{key}={answers[key]}")
return "\n".join(out).rstrip("\n") + "\n"
def adapter_choices() -> list[str]:
"""The adapters actually present, core's plus this project's own —
enumerated, never hardcoded, so a local/adapters/ entry shows up."""
names: list[str] = []
for base in (CORE / "adapters", LOCAL / "adapters"):
if base.is_dir():
for child in sorted(base.iterdir()):
if (child / "run").is_file() and child.name not in names:
names.append(child.name)
return names
def _ask(question: str, default: str, note: str = "") -> str:
if note:
print(f"\n{note}")
try:
answer = input(f" {question} [{default}]: ").strip()
except EOFError:
print()
raise _Skipped from None
return answer or default
def ask_questions(current: dict[str, str], answers: dict[str, str]) -> None:
"""Fill `answers` in place — in place because a Ctrl-D part-way through
keeps what was already answered."""
team = env_on(current.get("BOARD_SYNC", "")) or env_on(
current.get("BOARD_COMMIT_MOVES", ""))
while True:
reply = _ask("solo or team?", "team" if team else "solo",
note=TEAM_NOTE).lower()
if reply in ("solo", "s", "team", "t"):
break
print(" answer solo or team.")
team = reply.startswith("t")
answers["BOARD_COMMIT_MOVES"] = "1" if team else ""
answers["BOARD_SYNC"] = "1" if team else ""
choices = adapter_choices()
default_adapter = current.get("BOARD_AGENT_ADAPTER") or "claude"
if choices:
allowed = choices + ([default_adapter]
if default_adapter not in choices else [])
while True:
reply = _ask("which agent adapter?", default_adapter,
note=" Which coding agent runs headless jobs — "
f"here: {', '.join(choices)}.")
if reply in allowed:
break
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:
"""Write manager/local/.env when there is none — or rewrite it, from
its own current values, when asked to with --setup. Silent and
side-effect-free in every other case."""
exists = ENV_FILE.is_file()
if (exists and not forced) or not ENV_EXAMPLE.is_file():
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")
return
if not sys.stdin.isatty():
if exists:
print(f"--setup asks questions and there is no terminal to ask "
f"on — {_rel(ENV_FILE)} left as it is.\n")
else:
print(f"no {_rel(ENV_FILE)} — the defaults in {_rel(ENV_EXAMPLE)} "
f"apply; `python3 {_rel(Path(__file__).resolve())} --setup` "
f"asks the questions that write one.\n")
return
example = ENV_EXAMPLE.read_text(encoding="utf-8")
base = ENV_FILE.read_text(encoding="utf-8") if exists else example
current = env_values(example)
current.update(env_values(base))
if exists:
print(f"Rewriting {_rel(ENV_FILE)} — its current values are the "
f"defaults below.")
else:
print(f"No {_rel(ENV_FILE)} yet — a few questions and bench writes "
f"one.")
print("Enter takes the default in [brackets]; Ctrl-D skips the rest.")
answers: dict[str, str] = {}
try:
ask_questions(current, answers)
except _Skipped:
print(" skipped — the rest stay at their documented defaults.")
except KeyboardInterrupt:
print(f"\n\nCancelled — {_rel(ENV_FILE)} not written.\n")
return
ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
ENV_FILE.write_text(substitute(base, answers), encoding="utf-8")
print(f"\nWrote {_rel(ENV_FILE)} — every other setting is in there, "
f"commented; edit it any time.\n")
def adapter_name() -> str:
if os.environ.get("BOARD_AGENT_ADAPTER"):
return os.environ["BOARD_AGENT_ADAPTER"]
env_file = LOCAL / ".env"
if env_file.is_file():
for line in env_file.read_text(encoding="utf-8").splitlines():
key, _, value = line.strip().partition("=")
if key.strip() == "BOARD_AGENT_ADAPTER" and value.strip():
return value.strip().strip("'\"")
return "claude"
def main() -> int:
args = sys.argv[1:]
# Order is load-bearing: setup writes local/.env, which is one of the
# two things first_boot() reads as "this project has been here before".
first_boot_clean(dry_run="--dry-run" in args)
setup(dry_run="--dry-run" in args, forced="--setup" in args)
name = adapter_name()
passthrough = [a for a in args if a != "--setup"]
sys.stdout.flush() # the wire's output is a child's: keep the order
for base in (LOCAL / "adapters", CORE / "adapters"):
wire = base / name / "wire"
if wire.is_file():
return subprocess.call(
[sys.executable, str(wire), str(PROJECT), *passthrough])
print(f"agent adapter '{name}' has no wire script — looked in "
f"{LOCAL / 'adapters' / name} and {CORE / 'adapters' / name}.")
return 1
if __name__ == "__main__":
sys.exit(main())