diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c1a6e3f..e2cea80 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -39,7 +39,7 @@ body: id: version attributes: label: Franco version or commit - placeholder: "0.4.0, or the output of: git rev-parse --short HEAD" + placeholder: "0.5.0, or the output of: git rev-parse --short HEAD" validations: required: true - type: input diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f14e7e..92e5e54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,11 @@ # Release: push a tag vX.Y.Z (matching [project].version in pyproject.toml). # # 1. check the tag matches pyproject.toml and CHANGELOG.md has notes for it -# 2. build sdist + wheel, `twine check`, full test suite, installed-wheel test -# 3. release GitHub Release with those notes, the built files attached and -# build-provenance attestations +# 2. build sdist + wheel, `twine check`, full test suite, installed-wheel tests +# 3. pypi publish to https://pypi.org/project/franco/ with trusted +# publishing (no stored token) and PEP 740 attestations +# 4. release GitHub Release with those notes, the built files attached and +# build-provenance attestations (only after PyPI accepted them) # # The container image for the same tag is published by docker.yml. # How to cut a release: see "Releasing" in CONTRIBUTING.md. @@ -76,9 +78,31 @@ jobs: release-notes.md if-no-files-found: error - release: + pypi: needs: build runs-on: ubuntu-latest + # Trusted publishing: PyPI accepts uploads only from this workflow, in + # this environment, on this repository. Set up once on pypi.org (see + # "Releasing" in CONTRIBUTING.md). The environment only allows v* tags. + environment: + name: pypi + url: https://pypi.org/project/franco/${{ needs.build.outputs.version }}/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + attestations: true + + release: + needs: [build, pypi] + runs-on: ubuntu-latest permissions: contents: write id-token: write diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe9e02..bf5683f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ minor versions can still change behaviour. ## [Unreleased] +## [0.5.0] — 2026-10-16 + +### Added + +- **`pip install franco`.** Franco is on [PyPI](https://pypi.org/project/franco/) and works without a checkout: + - `franco init [DIR]` creates a **home folder**: `franco.yaml`, a private `.env`, editable `prompts/` and `skills/`, `data/` and a `.gitignore` that keeps credentials and contact data out of git. It never overwrites existing files. + - Franco finds its home as `$FRANCO_HOME`, else the source checkout (unchanged for clones and Docker), else the current folder when it has a `franco.yaml`, else `~/.franco`. + - The wheel bundles the default prompts, skills and templates. A file in the home folder wins; delete it to fall back to the shipped version. + - A pip-installed Franco never writes into its installed package. + - `franco install` only fetches Chromium when Franco was installed from PyPI. +- **Releases publish to PyPI** with trusted publishing (no stored token) and PEP 740 attestations, before the GitHub Release is created. + +### Fixed + +- `franco train` created `skills/` files without creating the folder first, which failed in a fresh home folder. +- The dashboard's setup check counted only a `.venv` inside the project as a virtual environment. + + ## [0.4.0] — 2026-10-16 Franco is now open source under [GortexAI/franco](https://github.com/GortexAI/franco), published as a container image, and released automatically. diff --git a/CLAUDE.md b/CLAUDE.md index 5073028..bcfa656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,15 @@ Walk through these steps conversationally. Ask one thing at a time. Don't overwh ### Step 1: Install Dependencies +If they just want to *use* Franco, install it from PyPI and create a home folder: + +```bash +pipx install franco # or: pip install franco, in a virtualenv +franco init ~/franco && cd ~/franco +``` + +The home folder holds franco.yaml, .env, editable prompts/ and skills/, and data/. Franco finds it as `$FRANCO_HOME`, else the source checkout, else the current folder when it has a franco.yaml, else `~/.franco`. If they cloned the repo (to change Franco itself), the clone is the home: + ```bash python3 -m venv .venv && source .venv/bin/activate && pip install -e . ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c596ffb..4b2f044 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,27 @@ The tag triggers two workflows: | `release.yml` | Checks that the tag matches `pyproject.toml` and that the CHANGELOG has notes for it. It then builds the sdist and wheel, runs the full suite and the installed-wheel test, and creates the GitHub Release with those notes, the files attached and provenance attestations. | | `docker.yml` | Publishes `ghcr.io/gortexai/franco:X.Y.Z`, `:X.Y` and `:latest` for linux/amd64 and linux/arm64, with an SBOM and an attestation. Every push to `main` also publishes `:edge`. | -Franco isn't published to PyPI yet. It expects to run from a checkout (it -keeps its config, prompts and data next to the code), so a `pip install` -from PyPI wouldn't work until that changes. +`release.yml` also publishes the sdist and wheel to +[PyPI](https://pypi.org/project/franco/), before the GitHub Release is created, +using **trusted publishing**: there is no PyPI token anywhere. PyPI accepts the +upload only from `release.yml` in this repository, running in the `pypi` +environment, which only `v*.*.*` tags can deploy to. + +One-time setup on pypi.org, by a maintainer: under **Your account → +Publishing**, add a (pending) trusted publisher for project `franco` with owner +`GortexAI`, repository `franco`, workflow `release.yml` and environment `pypi`. + +### How the package finds its files + +A pip-installed Franco has no checkout. `franco/paths.py` picks a *home +folder* (`$FRANCO_HOME`, else the checkout, else the current folder when it +has a `franco.yaml`, else `~/.franco`), and the wheel bundles the default +prompts, skills, `franco.yaml` and `.env.example` in `franco/_bundled`. +`setup.py` copies them in at build time from the repository's top-level +files, so there is only one copy of each in git. Files are read from the home +folder first and from the bundle otherwise. `tests/test_packaging.py` installs +the wheel into an empty directory and checks all of this, including that +Franco never writes into its installed package. ## Never commit diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9c54d77 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +# The sdist must carry what setup.py bundles into the wheel. +include franco.yaml .env.example LICENSE README.md CHANGELOG.md requirements.in requirements.txt +recursive-include prompts *.md +recursive-include skills *.md +exclude skills/product_knowledge.md skills/competitive_intel.md +recursive-include franco/web * +global-exclude __pycache__ *.py[cod] .DS_Store diff --git a/README.md b/README.md index f694be1..66ef0db 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,14 @@ There is no model API bill. Model calls go through `claude` (Claude Pro or Max) - Claude Code, logged in with `claude login`, on a Pro or Max plan. Codex is the alternative; see [below](#using-a-chatgpt-plan-codex). - A mailbox to send from. Google Workspace is recommended. +**Install from PyPI** (recommended): + ```bash -git clone https://github.com/GortexAI/franco.git && cd franco -python3.13 -m venv .venv && source .venv/bin/activate && pip install -e . +pipx install franco # or, in a virtualenv: pip install franco +franco init ~/franco && cd ~/franco # your home folder: config, .env, prompts, skills, data +franco install # Chromium, for LinkedIn and JavaScript-heavy sites -cp .env.example .env && $EDITOR .env # mail provider + verifier keys, documented inline +$EDITOR .env # mail provider + verifier keys, documented inline franco gmail auth # one-time browser login when provider: gmail franco gmail test @@ -62,6 +65,24 @@ franco dashboard # http://localhost:5555 franco run # the heartbeat loop ``` +Franco works in one **home folder**. It finds it in this order: +1. `$FRANCO_HOME`, when set; +2. the source checkout, when you run from a clone; +3. the current folder, when it has a `franco.yaml`; +4. `~/.franco` otherwise. + +`franco init` creates a home folder. The prompts and skills it copies there are yours to edit; delete one to go back to the version that ships with Franco. + +**Or run from a clone** (to change Franco itself): + +```bash +git clone https://github.com/GortexAI/franco.git && cd franco +python3.13 -m venv .venv && source .venv/bin/activate && pip install -e . +cp .env.example .env && $EDITOR .env +``` + +The clone is its own home folder: config, `prompts/`, `skills/` and `data/` live next to the code. + **Or run the published container image** (Linux, Intel or ARM). It already includes Chromium and the Claude CLI: ```bash @@ -523,7 +544,7 @@ Franco automates outreach, but **you are the sender.** ## Your data folder -Everything Franco knows and does lives in `data/`, next to the code, on your machine. It is created on first run and is **gitignored**. Never commit it, never put it in a shared drive, and never attach it to an issue. +Everything Franco knows and does lives in `data/` inside your [home folder](#quick-start) (next to the code in a clone, wherever you ran `franco init` for a pip install), on your machine. It is created on first run. In a clone and in a `franco init` folder it is **gitignored**. Never commit it, never put it in a shared drive, and never attach it to an issue. | File | What it holds | Sensitive? | |---|---|---| diff --git a/franco/brain.py b/franco/brain.py index cf3b0d3..ca22e29 100644 --- a/franco/brain.py +++ b/franco/brain.py @@ -19,7 +19,7 @@ logger = logging.getLogger("franco.brain") -from franco.paths import PROJECT_ROOT +from franco.paths import PROJECT_ROOT, resource PROMPTS_DIR = PROJECT_ROOT / "prompts" SKILLS_DIR = PROJECT_ROOT / "skills" @@ -632,7 +632,7 @@ async def is_within_budget( def load_prompt(self, prompt_name: str, **kwargs) -> str: """Load a prompt template from the prompts/ directory and fill in variables.""" - prompt_file = PROMPTS_DIR / f"{prompt_name}.md" + prompt_file = resource("prompts", f"{prompt_name}.md") try: template = prompt_file.read_text() except FileNotFoundError: @@ -653,7 +653,7 @@ def load_prompt(self, prompt_name: str, **kwargs) -> str: def load_skill(self, skill_name: str) -> str: """Load a skill knowledge file from the skills/ directory.""" - skill_file = SKILLS_DIR / f"{skill_name}.md" + skill_file = resource("skills", f"{skill_name}.md") try: return skill_file.read_text() except FileNotFoundError: diff --git a/franco/cli.py b/franco/cli.py index d215754..41cd2f8 100644 --- a/franco/cli.py +++ b/franco/cli.py @@ -35,12 +35,80 @@ def _check_python_version(): sys.exit(1) +HOME_GITIGNORE = """# Franco home: never commit credentials, contact data or real config +.env +*.env +franco.local.yaml +data/ +linkedin_cookies.json +skills/product_knowledge.md +skills/competitive_intel.md +""" + + +def cmd_init(args): + """Create a Franco home folder: config, .env, editable prompts and skills.""" + import shutil + from franco.paths import bundled + + target = Path(args.directory).expanduser().resolve() if args.directory else Path.cwd() + target.mkdir(parents=True, exist_ok=True) + made, kept = [], [] + + def place(src: Path, dest: Path, mode: int | None = None): + if dest.exists(): + kept.append(dest.relative_to(target).as_posix()) + return + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dest) + if mode is not None: + dest.chmod(mode) + made.append(dest.relative_to(target).as_posix()) + + place(bundled() / "franco.yaml", target / "franco.yaml") + place(bundled() / ".env.example", target / ".env", mode=0o600) + for kind in ("prompts", "skills"): + for src in sorted(bundled(kind).glob("*.md")): + if src.name in ("product_knowledge.md", "competitive_intel.md"): + continue # generated per product by `franco train`, never shipped + place(src, target / kind / src.name) + (target / "data").mkdir(exist_ok=True) + gitignore = target / ".gitignore" + if not gitignore.exists(): + gitignore.write_text(HOME_GITIGNORE) + made.append(".gitignore") + + print(f"\n Franco home: {target}") + if made: + print(f" Created: {', '.join(made)}") + if kept: + print(f" Kept your existing: {', '.join(kept)}") + print(f""" + Next, from that folder (Franco finds it by its franco.yaml), or with + FRANCO_HOME={target} set: + + cd {target} + franco install # Chromium, for LinkedIn and JS-heavy sites + claude login # the subscription Franco thinks with + $EDITOR .env # mail provider + verifier keys + franco setup # or: franco train https://your-site.com + franco dashboard + + Prompts and skills here are yours to edit; delete one to go back to the + version that ships with Franco. Keep .env and data/ private. +""") + + def cmd_install(args): """Install all dependencies including Playwright browsers.""" _check_python_version() print("\n Installing Franco dependencies...\n") - # Install Python packages + # Install Python packages (a pip-installed Franco already has them) + from franco.paths import checkout_root + if checkout_root() is None: + print(" [1/2] Python packages: already installed with Franco.\n") + return _install_browsers() print(" [1/2] Installing Python packages...") requirements = Path(args._project_root) / "requirements.txt" if requirements.exists(): @@ -58,8 +126,14 @@ def cmd_install(args): "(python3 -m venv .venv && source .venv/bin/activate).") sys.exit(1) print(" ✓ Python packages installed.\n") + _install_browsers() + + _ensure_importable(args._project_root) + print(" Franco is installed. Run 'franco setup' next.\n") - # Install Playwright browsers + +def _install_browsers(): + """Chromium for Playwright: used for LinkedIn and JavaScript-heavy sites.""" print(" [2/2] Installing Playwright browsers...") try: result = subprocess.run( @@ -74,9 +148,6 @@ def cmd_install(args): else: print(" ✓ Playwright browsers installed.\n") - _ensure_importable(args._project_root) - print(" Franco is installed. Run 'franco setup' next.\n") - def _ensure_importable(project_root: str): """Make sure `import franco` works outside the repo directory. @@ -991,6 +1062,13 @@ def main(): ) subparsers = parser.add_subparsers(dest="command") + # franco init + sub = subparsers.add_parser( + "init", help="Create a Franco home folder (config, .env, prompts, skills, data)") + sub.add_argument("directory", nargs="?", default="", + help="Folder to set up (default: the current folder)") + sub.set_defaults(func=cmd_init) + # franco install sub = subparsers.add_parser("install", help="Install dependencies") sub.set_defaults(func=cmd_install) diff --git a/franco/config.py b/franco/config.py index 26ff3c1..d71f0f2 100644 --- a/franco/config.py +++ b/franco/config.py @@ -586,5 +586,6 @@ def _find_config_file() -> str: raise ConfigFileNotFoundError( "franco.yaml not found in " + ", ".join(str(p.parent) for p in candidates) - + ". Create one from franco.yaml.example or run 'franco setup'." + + ". Run 'franco init' in an empty folder to create one, or point " + "FRANCO_HOME at the folder that has it." ) diff --git a/franco/dashboard.py b/franco/dashboard.py index cd5cd92..d0c35c1 100644 --- a/franco/dashboard.py +++ b/franco/dashboard.py @@ -172,9 +172,10 @@ async def get_setup_status(): # 1. Venv checks.append({ "id": "venv", "label": "Python virtual environment", - "done": (PROJECT_ROOT / ".venv").is_dir(), + "done": sys.prefix != sys.base_prefix or (PROJECT_ROOT / ".venv").is_dir(), "required": True, - "help": "Run: python3 -m venv .venv && source .venv/bin/activate && pip install -e .", + "help": ("Run Franco from a virtualenv: python3 -m venv .venv && " + "source .venv/bin/activate && pip install franco"), }) # 2. Env file @@ -498,6 +499,7 @@ async def start_franco(): _franco_process = subprocess.Popen( [sys.executable, "-m", "franco"], cwd=str(PROJECT_ROOT), + env={**os.environ, "FRANCO_HOME": str(PROJECT_ROOT)}, stdout=log_handle, stderr=log_handle, start_new_session=True, diff --git a/franco/paths.py b/franco/paths.py index c81418e..1f582a0 100644 --- a/franco/paths.py +++ b/franco/paths.py @@ -1,12 +1,84 @@ -"""Canonical filesystem locations for the Franco checkout. +"""Where Franco keeps its files. -Franco is repo-centric: config (franco.yaml, .env), knowledge (prompts/, -skills/), and state (data/) all live next to the package. ``resolve()`` -matters — when the package is reached through a symlink (some editable -installs), a bare ``Path(__file__).parent`` would point into +Franco works in one folder, its *home*: config (franco.yaml, +franco.local.yaml, .env), editable knowledge (prompts/, skills/) and state +(data/). The home is, in order: + +1. ``$FRANCO_HOME``, when set; +2. the source checkout this package lives in (a git clone, an editable + install, or the Docker image's /app). That is the repo-centric layout + Franco has always had; +3. the current directory, when it holds franco.yaml or franco.local.yaml + (a folder made with ``franco init``); +4. ``~/.franco`` otherwise. + +A pip-installed Franco never writes into site-packages: without a checkout +it uses 3 or 4. Prompts, skills and the config template also ship inside +the package (franco/_bundled, copied in at build time), so a home only needs +the files you want to change: ``resource()`` reads the home's copy first +and falls back to the bundled one. + +``resolve()`` matters: when the package is reached through a symlink (some +editable installs), a bare ``Path(__file__).parent`` would point into site-packages and Franco would silently read/write the wrong files. """ +import os from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parent.parent +PACKAGE_DIR = Path(__file__).resolve().parent +BUNDLED_DIR = PACKAGE_DIR / "_bundled" +CONFIG_NAMES = ("franco.local.yaml", "franco.yaml") +DEFAULT_HOME = Path.home() / ".franco" + + +def checkout_root() -> Path | None: + """The source tree around this package, if there is one. + + A checkout has prompts/ next to the package. An installed wheel never + does (site-packages has no prompts/), so this can't misfire there. + """ + root = PACKAGE_DIR.parent + if (root / "prompts").is_dir() and "site-packages" not in root.parts \ + and "dist-packages" not in root.parts: + return root + return None + + +def find_home(env: dict | None = None, cwd: Path | None = None) -> Path: + """Franco's home folder (see the module docstring for the order).""" + env = os.environ if env is None else env + explicit = (env.get("FRANCO_HOME") or "").strip() + if explicit: + return Path(explicit).expanduser().resolve() + checkout = checkout_root() + if checkout is not None: + return checkout + cwd = (cwd or Path.cwd()).resolve() + if any((cwd / name).is_file() for name in CONFIG_NAMES): + return cwd + return DEFAULT_HOME + + +# Kept under its historical name: every module joins paths onto it. +PROJECT_ROOT = find_home() + + +def resource(kind: str, name: str, home: Path | None = None) -> Path: + """A prompt, skill or template: the home's copy if it has one, else the + copy bundled with the package. ``kind`` is "prompts", "skills" or "" + (a top-level file such as "franco.yaml").""" + home = PROJECT_ROOT if home is None else home + local = home / kind / name if kind else home / name + if local.is_file(): + return local + bundled = BUNDLED_DIR / kind / name if kind else BUNDLED_DIR / name + return bundled if bundled.is_file() else local + + +def bundled(kind: str = "") -> Path: + """The bundled defaults (a checkout serves its own files).""" + if BUNDLED_DIR.is_dir(): + return BUNDLED_DIR / kind if kind else BUNDLED_DIR + root = checkout_root() or PACKAGE_DIR.parent + return root / kind if kind else root diff --git a/franco/setup.py b/franco/setup.py index 614613e..c0c6304 100644 --- a/franco/setup.py +++ b/franco/setup.py @@ -16,9 +16,9 @@ logger = logging.getLogger("franco.setup") -from franco.paths import PROJECT_ROOT +from franco.paths import PROJECT_ROOT, resource ENV_FILE = PROJECT_ROOT / ".env" -ENV_EXAMPLE = PROJECT_ROOT / ".env.example" +ENV_EXAMPLE = resource("", ".env.example") CONFIG_FILE = PROJECT_ROOT / "franco.yaml" diff --git a/franco/trainer.py b/franco/trainer.py index 84bdcb2..c0df46f 100644 --- a/franco/trainer.py +++ b/franco/trainer.py @@ -668,6 +668,7 @@ async def _generate_product_knowledge( """ skills_path = PROJECT_ROOT / "skills" / "product_knowledge.md" + skills_path.parent.mkdir(parents=True, exist_ok=True) skills_path.write_text(knowledge) print(f" Generated: skills/product_knowledge.md") @@ -716,6 +717,7 @@ async def _generate_battle_cards(self, product_info: dict, competitive_intel: di """ skills_path = PROJECT_ROOT / "skills" / "competitive_intel.md" + skills_path.parent.mkdir(parents=True, exist_ok=True) skills_path.write_text(cards) print(f" Generated: skills/competitive_intel.md") diff --git a/pyproject.toml b/pyproject.toml index e304d56..78557f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "franco" -version = "0.4.0" +version = "0.5.0" description = "Autonomous AI sales agent. Always Be Closing." readme = "README.md" requires-python = ">=3.11" @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Topic :: Office/Business", ] -# Keep in sync with requirements.txt (used by Docker builds). +# Keep in sync with requirements.in (requirements.txt is its generated lock). dependencies = [ "pyyaml>=6.0.1,<7", "python-dotenv>=1.0.1,<2", diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4c1237b --- /dev/null +++ b/setup.py @@ -0,0 +1,45 @@ +"""Build hook: bundle Franco's defaults inside the package. + +pyproject.toml holds all the metadata; this file only extends ``build_py`` +so that wheels (and installs from the sdist) carry the default prompts, +skills, config template and .env template in ``franco/_bundled``. A +pip-installed Franco reads them from there when its home folder has no copy +of its own (see franco/paths.py). + +The repository keeps these files at the top level, where people edit them, +so there is exactly one copy of each in git. +""" + +import shutil +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + +ROOT = Path(__file__).resolve().parent + +# What ships, by explicit allowlist: product_knowledge.md and +# competitive_intel.md are generated per product by `franco train` and must +# never be packaged, even when a maintainer builds from their own checkout. +BUNDLE_FILES = ("franco.yaml", ".env.example") +BUNDLE_DIRS = ("prompts", "skills") +NEVER_SHIP = {"product_knowledge.md", "competitive_intel.md"} + + +class BuildWithDefaults(build_py): + def run(self): + super().run() + dest = Path(self.build_lib) / "franco" / "_bundled" + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + for name in BUNDLE_FILES: + shutil.copyfile(ROOT / name, dest / name) + for folder in BUNDLE_DIRS: + (dest / folder).mkdir() + for src in sorted((ROOT / folder).glob("*.md")): + if src.name not in NEVER_SHIP: + shutil.copyfile(src, dest / folder / src.name) + + +setup(cmdclass={"build_py": BuildWithDefaults}) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index bf730e1..98e0b5b 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -103,25 +103,120 @@ async def main(): ''') -def test_installed_wheel_runs_codex_without_the_checkout(tmp_path): +def _build_and_install(tmp_path): + """Build the wheel from a copy of exactly what the sdist ships, and + install it into an empty --target directory.""" pytest.importorskip("setuptools") src = tmp_path / "src" src.mkdir() - for name in ("pyproject.toml", "README.md", "LICENSE"): + for name in ("pyproject.toml", "setup.py", "MANIFEST.in", "README.md", "LICENSE", + "franco.yaml", ".env.example"): shutil.copy(ROOT / name, src / name) - shutil.copytree(ROOT / "franco", src / "franco", - ignore=shutil.ignore_patterns("__pycache__")) - dist, site, work = tmp_path / "dist", tmp_path / "site", tmp_path / "work" - work.mkdir() + for folder in ("franco", "prompts", "skills"): + shutil.copytree(ROOT / folder, src / folder, + ignore=shutil.ignore_patterns("__pycache__", "product_knowledge.md", + "competitive_intel.md")) + dist, site = tmp_path / "dist", tmp_path / "site" subprocess.run([sys.executable, "-m", "pip", "wheel", "-q", "--no-deps", "--no-build-isolation", "-w", str(dist), str(src)], check=True, cwd=tmp_path, timeout=300) wheel = next(dist.glob("franco-*.whl")) subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--no-deps", "--target", str(site), str(wheel)], check=True, timeout=300) + return site + + +def test_installed_wheel_runs_codex_without_the_checkout(tmp_path): + site = _build_and_install(tmp_path) + work = tmp_path / "work" + work.mkdir() script = tmp_path / "check.py" script.write_text(CHECK) out = subprocess.run([sys.executable, "-I", str(script), str(site), str(FAKE), str(work)], cwd=work, capture_output=True, text=True, timeout=300) assert out.returncode == 0, out.stderr[-2000:] assert "WHEEL-OK" in out.stdout + + +# pip-installed Franco: `franco init` makes a home folder, Franco finds it by +# its franco.yaml, reads the home's own prompts first and the bundled ones +# otherwise, keeps its data there, and never writes into site-packages. +PREAMBLE = textwrap.dedent(''' + import os, sys + SITE = sys.argv[1] + sys.path.insert(0, SITE) + sys.meta_path[:] = [f for f in sys.meta_path + if "editable" not in type(f).__name__.lower() + and "editable" not in getattr(f, "__module__", "")] +''') + +INIT = PREAMBLE + textwrap.dedent(''' + from franco.cli import main + sys.argv = ["franco", "init", sys.argv[2]] + main() +''') + +USE_HOME = PREAMBLE + textwrap.dedent(''' + import asyncio + HOME = os.path.realpath(sys.argv[2]) + import franco + from franco import paths + assert os.path.realpath(franco.__file__).startswith(os.path.realpath(SITE)), franco.__file__ + assert paths.checkout_root() is None, paths.checkout_root() + assert str(paths.PROJECT_ROOT) == HOME, (paths.PROJECT_ROOT, HOME) + + from franco.config import load_config + assert load_config().outreach.mode == "generated" + + # The home's own copy wins; a deleted one falls back to the bundled copy. + assert str(paths.resource("prompts", "writer.md")).startswith(HOME) + os.remove(os.path.join(HOME, "skills", "signal_playbook.md")) + fallback = str(paths.resource("skills", "signal_playbook.md")) + assert fallback.startswith(os.path.realpath(SITE)) and "_bundled" in fallback, fallback + from franco.brain import Brain + assert len(Brain.__new__(Brain).load_skill("signal_playbook")) > 200 + + from franco.state import StateManager, DB_PATH + assert str(DB_PATH).startswith(HOME), DB_PATH + asyncio.run(StateManager().init_db()) + assert os.path.isfile(os.path.join(HOME, "data", "franco.db")) + + from fastapi.testclient import TestClient + import franco.dashboard as dash + c = TestClient(dash.app, base_url="http://localhost") + assert c.get("/static/app.js").status_code == 200 + assert c.get("/api/outbox").status_code == 200 + print("HOME-OK") +''') + + +def _snapshot(folder): + return {p: p.read_bytes() for p in folder.rglob("*") + if p.is_file() and "__pycache__" not in p.parts} + + +def test_installed_wheel_works_from_a_franco_init_home(tmp_path): + pytest.importorskip("fastapi") + site = _build_and_install(tmp_path) + assert (site / "franco" / "_bundled" / "prompts" / "writer.md").is_file() + assert (site / "franco" / "_bundled" / "franco.yaml").is_file() + assert not (site / "franco" / "_bundled" / "skills" / "product_knowledge.md").exists() + before = _snapshot(site) + + home = tmp_path / "my franco home" + init = tmp_path / "init.py" + init.write_text(INIT) + env = {k: v for k, v in os.environ.items() if k != "FRANCO_HOME"} + out = subprocess.run([sys.executable, "-I", str(init), str(site), str(home)], + cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120) + assert out.returncode == 0, out.stderr[-2000:] + assert (home / "franco.yaml").is_file() and (home / ".env").is_file() + assert (home / ".env").stat().st_mode & 0o077 == 0, "the .env must be private" + + use = tmp_path / "use.py" + use.write_text(USE_HOME) + out = subprocess.run([sys.executable, "-I", str(use), str(site), str(home)], + cwd=home, env=env, capture_output=True, text=True, timeout=300) + assert out.returncode == 0, out.stderr[-3000:] + assert "HOME-OK" in out.stdout + assert _snapshot(site) == before, "Franco wrote into its installed package" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..5a8ed96 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,126 @@ +"""Franco's home folder and bundled defaults (franco/paths.py), and the +commands that depend on them: `franco init` and `franco install`.""" + +import argparse +import stat +import sys +from pathlib import Path + +import pytest + +import franco.paths as paths + +REPO = Path(__file__).resolve().parent.parent + + +# ── which folder is home ── + + +def test_franco_home_env_wins(tmp_path): + assert paths.find_home({"FRANCO_HOME": str(tmp_path)}, cwd=Path("/")) == tmp_path.resolve() + + +def test_a_checkout_is_its_own_home(tmp_path): + assert paths.checkout_root() == REPO + assert paths.find_home({}, cwd=tmp_path) == REPO + + +def test_installed_franco_uses_a_folder_with_config_else_dot_franco(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "checkout_root", lambda: None) + assert paths.find_home({}, cwd=tmp_path) == paths.DEFAULT_HOME + (tmp_path / "franco.local.yaml").write_text("persona: {}\n") + assert paths.find_home({}, cwd=tmp_path) == tmp_path.resolve() + assert paths.DEFAULT_HOME == Path.home() / ".franco" + + +def test_site_packages_is_never_mistaken_for_a_checkout(tmp_path, monkeypatch): + fake_pkg = tmp_path / "lib" / "site-packages" / "franco" + fake_pkg.mkdir(parents=True) + (fake_pkg.parent / "prompts").mkdir() # even with a stray prompts/ + monkeypatch.setattr(paths, "PACKAGE_DIR", fake_pkg) + assert paths.checkout_root() is None + + +# ── home copy first, bundled copy otherwise ── + + +def test_resource_prefers_the_home_copy(tmp_path, monkeypatch): + bundle = tmp_path / "bundle" + (bundle / "skills").mkdir(parents=True) + (bundle / "skills" / "x.md").write_text("bundled") + monkeypatch.setattr(paths, "BUNDLED_DIR", bundle) + home = tmp_path / "home" + (home / "skills").mkdir(parents=True) + + assert paths.resource("skills", "x.md", home=home) == bundle / "skills" / "x.md" + (home / "skills" / "x.md").write_text("mine") + assert paths.resource("skills", "x.md", home=home) == home / "skills" / "x.md" + # Unknown everywhere: the home path, so errors name where to put it. + assert paths.resource("skills", "nope.md", home=home) == home / "skills" / "nope.md" + + +# ── franco init ── + + +def _init(target): + from franco.cli import cmd_init + cmd_init(argparse.Namespace(directory=str(target))) + + +def test_init_creates_a_complete_private_home(tmp_path, capsys): + home = tmp_path / "home" + _init(home) + assert (home / "franco.yaml").read_text() == (REPO / "franco.yaml").read_text() + assert (home / ".env").read_text() == (REPO / ".env.example").read_text() + assert {p.name for p in (home / "prompts").glob("*.md")} == \ + {p.name for p in (REPO / "prompts").glob("*.md")} + assert (home / "skills" / "email_frameworks.md").is_file() + assert (home / "data").is_dir() + gitignore = (home / ".gitignore").read_text() + assert ".env" in gitignore and "data/" in gitignore and "franco.local.yaml" in gitignore + if sys.platform != "win32": + assert stat.S_IMODE((home / ".env").stat().st_mode) == 0o600 + assert "Franco home" in capsys.readouterr().out + + +def test_init_never_overwrites_and_never_copies_generated_skills(tmp_path, monkeypatch): + home = tmp_path / "home" + (home / "skills").mkdir(parents=True) + (home / "franco.yaml").write_text("mine\n") + (home / ".env").write_text("SECRET=1\n") + (home / "skills" / "email_frameworks.md").write_text("edited\n") + + bundle = tmp_path / "bundle" + (bundle / "skills").mkdir(parents=True) + (bundle / "prompts").mkdir() + for name in ("franco.yaml", ".env.example"): + (bundle / name).write_text("template\n") + for name in ("email_frameworks.md", "product_knowledge.md", "competitive_intel.md"): + (bundle / "skills" / name).write_text("bundled\n") + monkeypatch.setattr(paths, "bundled", lambda kind="": bundle / kind if kind else bundle) + + _init(home) + _init(home) # running it twice is harmless + assert (home / "franco.yaml").read_text() == "mine\n" + assert (home / ".env").read_text() == "SECRET=1\n" + assert (home / "skills" / "email_frameworks.md").read_text() == "edited\n" + assert not (home / "skills" / "product_knowledge.md").exists() + assert not (home / "skills" / "competitive_intel.md").exists() + + +# ── franco install ── + + +def test_install_outside_a_checkout_only_fetches_the_browser(monkeypatch): + import franco.cli as cli + calls = [] + + class Done: + returncode = 0 + + monkeypatch.setattr(paths, "checkout_root", lambda: None) + monkeypatch.setattr(cli.subprocess, "run", lambda args, **kw: calls.append(args) or Done()) + monkeypatch.setattr(cli, "_check_python_version", lambda: None) + cli.cmd_install(argparse.Namespace(_project_root="/nowhere")) + assert calls == [[sys.executable, "-m", "playwright", "install", "chromium"]] + assert not any("pip" in a for call in calls for a in call)