← refacto-feature-scout
"""The guided setup: everything the maintainer's hand-tuned setups had, asked in plain words.
Ken (2026-09-09): "add a startup, setup process that lets a user tell the system what their
project is about, setup their preferred LLMs, etc. Stuff that is provided by the internal
system, but not included for general use."
"""
import json
import subprocess
import pytest
from scout import init as initmod
from scout.config import build_model, load_env, scan_model, scan_time
from scout.init import _model_choice, _never_run_patterns, _time_of_day, run_init
from scout.llm import Access
from scout.projects import PROJECT_FILE, read_project_file
@pytest.fixture(autouse=True)
def their_own_claude_is_fine(monkeypatch):
monkeypatch.setattr(initmod, "require_llm_access",
lambda *a, **k: Access("subscription", "your Claude login on this computer"))
def scripted(answers):
left = list(answers)
def ask(question, suggested=""):
return (left.pop(0) if left else "") or suggested
return ask
def a_project(tmp_path):
folder = tmp_path / "shop"
folder.mkdir()
(folder / "package.json").write_text(json.dumps({"scripts": {"test": "vitest run", "typecheck": "tsc"}}))
subprocess.run(["git", "init", "-q"], cwd=folder, check=True)
return folder
def test_the_whole_setup_in_one_sitting(tmp_path, monkeypatch):
folder = a_project(tmp_path)
env_file = tmp_path / "settings.env"
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
monkeypatch.setenv("SCOUT_ENV_FILE", str(env_file))
installed = []
said = []
run_init(
ask=scripted([
str(folder), "Shop", "Sells things.", "Shoppers.", "online shoppers", # project
"", "shopping", "shop app", # sources
"", # tests: keep the detected one
"https://shop.example.com", # url
"Look at what Shopify merchants complain about.", # research notes
"crypto payments, gambling", # avoid
"feature, fix", # categories
"Free, Pro", # plans
"Never change the database layout.", # house rules
"npm run e2e, npx playwright", # never run
"6", # ideas per day
"35", # spend limit
"best", "balanced", # models
"6:30", # scan time
"yes", # background (Mac only)
]),
say=said.append, folder=folder, tester=lambda f, c: (True, "ok"), use_ai=False,
installer=lambda h, m, say: installed.append((h, m)) or True, env_file=env_file,
)
st = json.loads((folder / PROJECT_FILE).read_text())
assert st["url"] == "https://shop.example.com"
assert st["research_notes"].startswith("Look at what Shopify")
assert st["avoid"] == ["crypto payments", "gambling"]
assert st["categories"] == ["feature", "fix"]
assert st["plans"] == ["Free", "Pro"]
assert st["house_rules"] == "Never change the database layout."
assert st["never_run"] == ["Bash(npm run e2e*)", "Bash(npx playwright*)"]
assert st["ideas_per_scan"] == 6 and st["spend_limit_usd"] == 35.0
assert st["extra_checks"] == ["npm run typecheck"], "detected checks are kept"
# The project object carries it all, and no key-shaped secret is anywhere near it.
p = read_project_file(folder)
assert p.plans == ["Free", "Pro"] and p.never_run[0] == "Bash(npm run e2e*)"
# This computer's own choices live in the settings file, not the project.
machine = load_env(env_file)
assert machine["SCOUT_SCAN_MODEL"] == "opus" and machine["SCOUT_BUILD_MODEL"] == "sonnet"
assert machine["SCOUT_SCAN_HOUR"] == "6" and machine["SCOUT_SCAN_MINUTE"] == "30"
assert "opus" not in (folder / PROJECT_FILE).read_text()
import platform
if platform.system() == "Darwin":
assert installed == [(6, 30)]
assert any("runs in the background" in line for line in said)
assert any("feature-scout setup" in line for line in said), "told how to change answers later"
def test_pressing_return_all_the_way_gives_a_sensible_setup(tmp_path, monkeypatch):
folder = a_project(tmp_path)
env_file = tmp_path / "settings.env"
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
monkeypatch.setenv("SCOUT_ENV_FILE", str(env_file))
run_init(ask=scripted([str(folder), "Shop", "s", "p", "p"]), say=lambda m: None, folder=folder,
tester=lambda f, c: (True, "ok"), use_ai=False, installer=lambda h, m, say: True, env_file=env_file)
st = json.loads((folder / PROJECT_FILE).read_text())
assert st["categories"] == ["feature", "improvement", "fix", "data", "report", "automation"]
assert st["plans"] == [] and st["never_run"] == [] and st["ideas_per_scan"] == 4 and st["spend_limit_usd"] == 20.0
machine = load_env(env_file)
assert machine.get("SCOUT_SCAN_MODEL", "") == "" and machine.get("SCOUT_BUILD_MODEL", "") == ""
assert (machine["SCOUT_SCAN_HOUR"], machine["SCOUT_SCAN_MINUTE"]) == ("8", "15")
assert "SCOUT_OPERATOR_EMAIL" not in machine, "there is no email to set up any more"
def test_running_setup_again_offers_the_earlier_answers(tmp_path, monkeypatch):
folder = a_project(tmp_path)
env_file = tmp_path / "settings.env"
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
monkeypatch.setenv("SCOUT_ENV_FILE", str(env_file))
first = [str(folder), "Shop", "Sells things.", "Shoppers.", "shoppers", "", "shopping", "shop app", "",
"https://shop.example.com", "notes", "gambling", "", "Free, Pro", "rules", "npx playwright", "5", "12",
"fast", "best", "7:00", "no"]
run_init(ask=scripted(first), say=lambda m: None, folder=folder, tester=lambda f, c: (True, "ok"),
use_ai=False, env_file=env_file)
seen = {}
def ask(question, suggested=""):
seen[question.strip()[:40]] = suggested
return suggested
run_init(ask=ask, say=lambda m: None, folder=folder, tester=lambda f, c: (True, "ok"), use_ai=False,
env_file=env_file)
sugg = list(seen.values())
assert "Shop" in sugg and "Sells things." in sugg and "https://shop.example.com" in sugg
assert "shopping" in sugg and "Free, Pro" in sugg and "npx playwright" in sugg and "notes" in sugg
assert "fast" in sugg and "best" in sugg and "07:00" in sugg
st = json.loads((folder / PROJECT_FILE).read_text())
assert st["plans"] == ["Free", "Pro"] and st["spend_limit_usd"] == 12.0, "unchanged answers survive a re-run"
def test_the_chosen_claude_reaches_the_research_and_the_builder(tmp_path, monkeypatch):
env_file = tmp_path / "settings.env"
monkeypatch.setenv("SCOUT_ENV_FILE", str(env_file))
from scout import config
monkeypatch.setattr(config, "ENV_FILE", env_file)
assert scan_model() is None and build_model() is None and scan_time() == (8, 15)
config.save_setting("SCOUT_SCAN_MODEL", "opus", env_file)
config.save_setting("SCOUT_BUILD_MODEL", "haiku", env_file)
config.save_setting("SCOUT_SCAN_HOUR", "6", env_file); config.save_setting("SCOUT_SCAN_MINUTE", "5", env_file)
assert scan_model() == "opus" and build_model() == "haiku" and scan_time() == (6, 5)
config.save_setting("SCOUT_SCAN_MODEL", "", env_file)
assert scan_model() is None, "an empty choice removes the line"
assert "SCOUT_SCAN_MODEL" not in env_file.read_text()
def test_answers_are_read_the_way_people_type_them():
assert _model_choice("best") == "opus" and _model_choice("Balanced") == "sonnet"
assert _model_choice("fast") == "haiku" and _model_choice("") == "" and _model_choice("default") == ""
assert _model_choice("opus") == "opus" and _model_choice("gpt-9") == ""
assert _time_of_day("6:30") == (6, 30) and _time_of_day("18:05") == (18, 5)
assert _time_of_day("6pm") == (18, 0) and _time_of_day("12am") == (0, 0) and _time_of_day("7") == (7, 0)
assert _time_of_day("noonish") == (8, 15) and _time_of_day("25:00") == (8, 15)
assert _never_run_patterns("npm run verify, `npx playwright`, Bash(rm *)") == \
["Bash(npm run verify*)", "Bash(npx playwright*)", "Bash(rm *)"]
def test_the_background_installer_fills_in_this_machines_paths_and_time(tmp_path):
from pathlib import Path
script = Path(__file__).resolve().parents[1] / "scripts" / "install-launchd.sh"
text = script.read_text()
for placeholder in ("__SCOUT_BIN__", "__LOG_DIR__", "__SCAN_HOUR__", "__SCAN_MINUTE__", "__HOME__"):
assert placeholder in text
plist = (Path(__file__).resolve().parents[1] / "launchd" / "com.featurescout.daily.plist").read_text()
assert "__SCOUT_BIN__" in plist and "__SCAN_HOUR__" in plist and "__LOG_DIR__" in plist
assert "kennethrona" not in plist and "Library/FeatureScout" not in plist