← refacto-feature-scout
"""The projects Feature Scout looks after, and what it knows about each one.
Settings used to live in this package's source, which meant only its author could point it at
anything. Now each project describes itself in a file:
<project folder>/.feature-scout.json what the project is, where ideas come from, how to test it
<data folder>/projects.json which folders on this computer are registered
The file is JSON rather than TOML because `feature-scout init` has to write it, and Python can
read TOML without help but cannot write it. Nothing secret belongs in either file — the project
one usually ends up saved alongside the code.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .config import DATA_DIR, IDEAS_PER_PLATFORM
PROJECT_FILE = ".feature-scout.json"
KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,39}$")
DEFAULT_CATEGORIES = ["feature", "improvement", "fix", "data", "report", "automation"]
# A project file is usually saved with the code, so a key pasted into it would end up shared.
SECRET_FIELDS = ("anthropic_api_key", "api_key", "apikey", "token", "secret", "password",
"access_key", "client_secret", "refresh_token")
class ConfigProblem(Exception):
"""Something about a project's settings needs a person to fix it, said in plain words."""
def registry_path() -> Path:
return Path(os.environ.get("SCOUT_PROJECTS_FILE") or DATA_DIR / "projects.json")
@dataclass(frozen=True)
class Project:
key: str
name: str
folder: Path
what_it_is: str = ""
who_uses_it: str = ""
url: str | None = None
topic: str = "" # a short clause after the name, e.g. " (a recipe app)"
audience: str = "" # who to research for, e.g. "home cooks"
research_notes: str = "" # where this project's ideas come from, in the owner's words
context_files: list[str] = field(default_factory=list)
house_rules: str = ""
# Commands the builder is not allowed to run, as tool patterns such as "Bash(npx playwright*)".
# Enforced by Claude's own permission system, not just asked for in the prompt. Use it for
# anything that cannot work where the builder runs (browser suites with no browser installed)
# and would otherwise leave it waiting for hours.
never_run: list[str] = field(default_factory=list)
# The plans the product has or is about to have, e.g. ["Free", "Pro"]. When set, the
# researcher says which plan each idea belongs in (and why), so the owner can see the
# paid tier taking shape idea by idea.
plans: list[str] = field(default_factory=list)
sources: dict[str, list[str]] = field(default_factory=dict)
areas: list[str] = field(default_factory=list)
categories: list[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
avoid: list[str] = field(default_factory=list)
test_command: str = ""
extra_checks: list[str] = field(default_factory=list)
install_hint: str = ""
deploy_command: str | None = None
link_dirs: tuple[str, ...] = ()
ideas_per_scan: int = IDEAS_PER_PLATFORM
spend_limit_usd: float = 0.0 # 0 means no limit
librarymode_url: str | None = None
@property
def reddit_feeds(self) -> list[str]:
return list(self.sources.get("reddit") or [])
@property
def signal_urls(self) -> list[str]:
return list(self.sources.get("urls") or [])
@property
def search_terms(self) -> list[str]:
return list(self.sources.get("search_terms") or [])
# The two below exist only while the prompts still speak in the old shape; they go away
# when the prompts learn to take a project's own words (see the plan, stage 3).
@property
def venue(self) -> str:
return " and ".join(self.areas) if self.areas else self.name
@property
def description(self) -> str:
return " ".join(p for p in (self.what_it_is.strip(), self.who_uses_it.strip()) if p)
# Field names used before settings moved into a file. Accepted for one release so that a
# hand-written file from an early copy of the tool still loads.
OLD_NAMES = {"repo": "folder", "description": "what_it_is", "test_cmd": "test_command",
"deploy_cmd": "deploy_command", "node_module_dirs": "link_dirs"}
def from_dict(data: dict[str, Any], folder: Path, key: str | None = None) -> Project:
d = dict(data)
for old, new in OLD_NAMES.items():
if old in d and new not in d:
d[new] = d.pop(old)
if "venue" in d and "areas" not in d: # "Polymarket and Kalshi"
d["areas"] = [p.strip() for p in str(d.pop("venue")).split(" and ") if p.strip()]
if "signal_urls" in d:
d.setdefault("sources", {}).setdefault("urls", d.pop("signal_urls"))
for name in SECRET_FIELDS:
if name in d:
raise ConfigProblem(
f"There's a {name.replace('_', ' ')} in {folder / PROJECT_FILE}. Take it out — this "
"file gets saved with your project, so anything in it can end up shared. Run "
"`feature-scout doctor` to set up your Claude access instead.")
d.pop("version", None)
d.pop("folder", None)
named_in_file = str(d.pop("key", "") or "")
known = {f for f in Project.__dataclass_fields__ if f not in ("key", "folder")}
for name in sorted(set(d) - known): # forwards-compatible: a newer file still loads
d.pop(name)
if "link_dirs" in d:
d["link_dirs"] = tuple(d["link_dirs"] or ())
return Project(key=key or named_in_file or folder.name, folder=Path(folder), **d)
def read_project_file(folder: Path, key: str | None = None) -> Project:
path = Path(folder) / PROJECT_FILE
if not path.exists():
raise ConfigProblem(
f"There's no {PROJECT_FILE} in {folder}. Go to that folder and run `feature-scout init`.")
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as e:
raise ConfigProblem(f"I couldn't read {path} — it isn't valid JSON ({e}).") from e
return from_dict(data, Path(folder), key)
def validate(project: Project) -> list[str]:
"""Problems worth telling a person about, in words they can act on."""
problems: list[str] = []
if not KEY_RE.match(project.key):
problems.append(f'"{project.key}" won\'t work as a short name — use lowercase letters, '
"numbers and dashes.")
if not project.name.strip():
problems.append("This project has no name.")
if not project.folder.exists():
problems.append(f"I can't find the folder {project.folder}.")
elif not (project.folder / ".git").exists():
problems.append(f"{project.folder} isn't saved with git yet, so I can't work on a copy of "
"it or put things back if something goes wrong.")
for rel in project.context_files:
if project.folder.exists() and not (project.folder / rel).exists():
problems.append(f"I was told to read {rel}, but there's no such file in {project.folder}.")
if project.ideas_per_scan < 1:
problems.append("Asking for fewer than one idea a day means there's nothing to do.")
return problems
# ---- the machine's list of projects ----
def load_registry(path: Path | None = None) -> dict[str, Any]:
p = path or registry_path()
if not p.exists():
return {"version": 1, "default": None, "projects": {}}
try:
data = json.loads(p.read_text())
except json.JSONDecodeError as e:
raise ConfigProblem(f"I couldn't read your list of projects at {p} ({e}).") from e
data.setdefault("projects", {})
data.setdefault("default", None)
return data
def save_registry(data: dict[str, Any], path: Path | None = None) -> Path:
p = path or registry_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data, indent=2) + "\n")
return p
def load_projects(path: Path | None = None) -> dict[str, Project]:
"""Every registered project. An entry may carry its settings inline (that is how the
operator's own cloud machine is configured, where the code is a throwaway copy)."""
reg = load_registry(path)
out: dict[str, Project] = {}
for key, entry in (reg.get("projects") or {}).items():
folder = Path(entry.get("folder", ""))
settings = entry.get("settings")
out[key] = from_dict(settings, folder, key) if settings else read_project_file(folder, key)
return out
def register(key: str, folder: Path, *, make_default: bool = True, path: Path | None = None) -> None:
reg = load_registry(path)
reg["projects"][key] = {"folder": str(Path(folder).resolve())}
if make_default or not reg.get("default"):
reg["default"] = key
save_registry(reg, path)
def find_project_folder(start: Path) -> Path | None:
"""The nearest folder at or above `start` that has been set up."""
here = Path(start).resolve()
for folder in [here, *here.parents]:
if (folder / PROJECT_FILE).exists():
return folder
return None
def resolve(key: str | None = None, cwd: Path | None = None,
path: Path | None = None) -> Project:
"""Which project a command means: the one asked for, the one you're standing in, or the
default. Anything ambiguous is an error that says what to do about it."""
projects = load_projects(path)
if key:
if key not in projects:
known = ", ".join(sorted(projects)) or "none yet"
raise ConfigProblem(f'I don\'t know a project called "{key}". Ones I know: {known}.')
return projects[key]
folder = find_project_folder(cwd or Path.cwd())
if folder:
for p in projects.values():
if p.folder.resolve() == folder.resolve():
return p
return read_project_file(folder) # set up but never registered — still usable
reg = load_registry(path)
if reg.get("default") and reg["default"] in projects:
return projects[reg["default"]]
if len(projects) == 1:
return next(iter(projects.values()))
if not projects:
raise ConfigProblem("You haven't set up a project yet. Go to your project's folder and "
"run `feature-scout init`.")
raise ConfigProblem("I look after more than one project. Say which one with --project "
f"({', '.join(sorted(projects))}).")