Advertisement

← refacto-feature-scout

scout/projects.py

10.6 KB Download file
"""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))}).")