Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
```
Expand Down
24 changes: 21 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,39 @@ 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

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
Expand Down Expand Up @@ -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? |
|---|---|---|
Expand Down
6 changes: 3 additions & 3 deletions franco/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
88 changes: 83 additions & 5 deletions franco/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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(
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion franco/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
6 changes: 4 additions & 2 deletions franco/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading