Skip to content

Commit a2c0326

Browse files
authored
Merge pull request #41 from matyasstoch/feature/global-install-launch
Support installing FrontierAgent once and launching from any project
2 parents 047b696 + c2ecd11 commit a2c0326

21 files changed

Lines changed: 2080 additions & 36 deletions

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,25 @@ Initial open-source release of FrontierAgent.
3434
- Clean-machine Linux + NVIDIA installation and release-certification guide,
3535
distinguishing deployment health from production agent correctness.
3636

37+
- Standalone installation with `uv tool install`: the wheel now ships the
38+
provider registry, and `frontier-agent` runs from any directory without a
39+
checkout. An optional user env file (`$XDG_CONFIG_HOME/apodex/env`, default
40+
`~/.config/apodex/env`, override with `APODEX_ENV_FILE`) holds the endpoint
41+
below exported variables and the launch directory's `.env`; a key defined
42+
next to a base URL is only applied together with that base URL.
43+
- `APODEX_BUILD_CONTEXT` names a checkout to build `apodex:local` from when the
44+
installed CLI is not one. Without an image, a checkout, or an explicit
45+
`APODEX_IMAGE`, the Docker path stops with the options instead of silently
46+
running natively.
47+
3748
### Fixed
3849

50+
- Native mode puts the CLI's own Python environment ahead of the inherited
51+
`PATH`, so `read_file`, `download_file`, and `python3` inside `bash` use the
52+
interpreter the CLI was installed with rather than a system Python.
53+
- The Docker launcher forwards the resolved runtime variables (exported
54+
environment, launch directory `.env`, user env file) into the container by
55+
name with `docker run -e NAME`, so an exported value now takes precedence over
56+
the checkout's `.env` inside the container as it already did natively.
3957
- Apply benchmark question limits after seeded shuffling so repeated runs can
4058
sample different questions while `--no-shuffle` keeps canonical ordering.

‎README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,26 @@ document packages are intentionally optional in native mode; the agent installs
169169
only what a task actually needs into `<project>/.apodex/runtime/native`. The
170170
`apodex` command is retained as a compatibility alias.
171171

172+
### Install once, launch from any project
173+
174+
To run `frontier-agent` like any other command-line tool, install it from this
175+
repository with `uv` and keep the endpoint in one user file:
176+
177+
```bash
178+
uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git
179+
180+
# Put OPENAI_API_KEY, OPENAI_BASE_URL and OPENAI_MODEL into
181+
# ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env and chmod 600 it.
182+
183+
cd /path/to/project
184+
frontier-agent
185+
```
186+
187+
Exported variables and a project `.env` still take precedence over the user
188+
file. On macOS with Docker running, the container image has to be built once
189+
from a clone. [Install once and launch from any project](docs/install/global-install.md)
190+
covers the PATH setup, the precedence rules, the Docker step, and updating.
191+
172192
Prefer a script that does all of the above? `./scripts/run-macos.sh` and
173193
`./scripts/run-linux.sh` set up a hosted-endpoint install, and
174194
`./scripts/run-linux-gpu.sh --install-system-deps --setup-only` prepares a native,

‎apodex/README.md‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,18 @@ is enough.
6767

6868
## Install and run
6969

70-
Run from the repository root, so `frontier_agent`, `plugins` and `workflows`
71-
import:
70+
Two ways to install. As a standalone tool, so the command works from any
71+
directory:
72+
73+
```bash
74+
uv tool install --python 3.12 git+https://github.com/ApodexAI/FrontierAgent.git
75+
# credentials in ${XDG_CONFIG_HOME:-$HOME/.config}/apodex/env, or exported;
76+
# see docs/install/global-install.md
77+
cd /path/to/your/repo && frontier-agent
78+
```
79+
80+
Or from a checkout, run from the repository root so `frontier_agent`,
81+
`plugins` and `workflows` import from the source tree:
7282

7383
```bash
7484
uv sync
@@ -119,14 +129,24 @@ attached through the same session input manager; ordinary text is inserted into
119129
the prompt. `Cmd+V` remains the terminal's normal text paste shortcut.
120130

121131
This is a local, open-source BYOK tool: there is no account or `login` command.
122-
Keys stay in your environment or local `.env`; the TUI never asks for or displays
123-
them. Startup validates the local configuration before opening the TUI, and
132+
Keys stay in your environment, a local `.env`, or the optional user file
133+
`$XDG_CONFIG_HOME/apodex/env` (default `~/.config/apodex/env`, override with
134+
`APODEX_ENV_FILE`); the TUI never asks for or displays them. Precedence is CLI
135+
options, then exported variables, then the launch directory's `.env`, then the
136+
user file. The user file is read literally, without `${VAR}` expansion, and a
137+
key it defines next to a base URL is only applied together with that base URL.
138+
Startup validates the local configuration before opening the TUI, and
124139
`/config` shows only safe diagnostics such as provider, model, endpoint host and
125140
whether the required key is configured.
126141

127142
The first `--docker` run builds the image, which takes a few minutes
128143
(LibreOffice and the document readers are large); later runs reuse it.
129-
`APODEX_IMAGE` overrides the tag.
144+
`APODEX_IMAGE` overrides the tag. Building needs a source checkout. A tool
145+
installed with `uv tool install` has none, so it uses an image that is already
146+
present, builds from the clone named by `APODEX_BUILD_CONTEXT`, or pulls an
147+
explicit `APODEX_IMAGE`; with none of those it stops and lists the options
148+
instead of running natively unannounced. Configured variables cross into the
149+
container by name (`docker run -e NAME`), never as values on the command line.
130150

131151
On Linux, native mode is the default. On macOS, Docker remains preferred when
132152
its daemon is reachable, with automatic fallback to native mode. Native mode

‎apodex/cli.py‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,22 @@
2525
from apodex.session import TerminalSession
2626
from apodex.terminal import resolve_terminal_ui
2727
from apodex.tui.themes import CLI_THEME_NAMES
28+
from apodex.userenv import EnvResolution, load_environment
2829

2930
if TYPE_CHECKING:
3031
from apodex.config import ModelConfig
3132

3233

33-
def _load_env() -> None:
34+
def _load_env() -> EnvResolution:
3435
"""Load a ``.env`` (keys/base-url/model) from the launch directory or an
35-
ancestor, the way FrontierAgent's own entry points do. ``override=False`` so
36-
real environment variables and CLI flags always win. Must run **before**
37-
any ``chdir`` so it finds the repo's ``.env`` rather than the target repo.
36+
ancestor, the way FrontierAgent's own entry points do, then the optional
37+
user env file underneath it. ``override=False`` so real environment
38+
variables and CLI flags always win. Must run **before** any ``chdir`` so it
39+
finds the repo's ``.env`` rather than the target repo, and before native
40+
mode rewrites ``HOME``/``XDG_CONFIG_HOME``, so the user file is read from
41+
the user's real config directory. See :mod:`apodex.userenv`.
3842
"""
39-
try:
40-
from dotenv import find_dotenv, load_dotenv
41-
except Exception:
42-
return
43-
load_dotenv(".env", override=False)
44-
found = find_dotenv(usecwd=True)
45-
if found:
46-
load_dotenv(found, override=False)
43+
return load_environment()
4744

4845

4946
# (substring in an engine log message) -> clean one-line note to surface
@@ -301,7 +298,13 @@ async def _amain(argv: list[str] | None = None) -> int:
301298
# keys are available (the standalone CLI isn't bootstrapped by the app).
302299
# (The fully-local toolchain guarantee — incl. dropping E2B_API_KEY — is
303300
# owned by TerminalSession._authorize_workspace.)
304-
_load_env()
301+
env_resolution = _load_env()
302+
# Secret-free by construction (names and paths only). Printed now so the
303+
# explanation precedes whatever the note is about — a preflight failure
304+
# over a withheld key, say — and only once: the TUI path alone repeats
305+
# them in its transcript, since its alternate screen covers stderr.
306+
for note in env_resolution.notes:
307+
print(f"apodex: {note}", file=sys.stderr)
305308

306309
# Textual's Kitty keyboard negotiation drops IME commits in iTerm2. Set
307310
# the compatibility fallback before either starting the native TUI or
@@ -377,7 +380,10 @@ async def _amain(argv: list[str] | None = None) -> int:
377380
if a != "--docker"]
378381
docker_ok, docker_reason = docker_available()
379382
if args.docker or docker_ok:
380-
return run_in_container(passthrough, cwd=cwd)
383+
return run_in_container(
384+
passthrough, cwd=cwd,
385+
forward_env=env_resolution.forwarded_names(),
386+
)
381387
print(
382388
f"apodex: Docker is unavailable ({docker_reason}); using native mode.",
383389
file=sys.stderr,
@@ -517,7 +523,10 @@ async def _amain(argv: list[str] | None = None) -> int:
517523
# stderr is written moments before Textual takes the alternate screen, so a
518524
# warning printed here is gone by the time the TUI is up. The TUI path
519525
# carries them into the transcript instead; line mode prints as before.
520-
startup_warnings = [warning.message for warning in runtime_config.warnings]
526+
startup_warnings = [
527+
*(env_resolution.notes if use_tui else ()),
528+
*(warning.message for warning in runtime_config.warnings),
529+
]
521530
if not use_tui:
522531
for message in startup_warnings:
523532
print(f"warning: {message}", file=sys.stderr)

‎apodex/docker.py‎

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@
1111
- a dedicated ``.apodex/runs/<session-id>/outputs`` directory, read-write at
1212
``/outputs``;
1313
- ``~/.apodex`` (session history, traces), so ``--resume`` works across runs;
14-
- ``.env`` from the repo, for model and search credentials.
14+
- ``.env`` from the repo, for model and search credentials, plus the resolved
15+
runtime variables the host CLI loaded (exported environment, the launch
16+
directory's ``.env``, the user env file) — forwarded by *name* with
17+
``-e NAME`` so Docker reads each value from the process environment and no
18+
secret ever lands on a command line.
1519
1620
The image is built on first use and reused after that. It is the same
1721
``Dockerfile`` the benchmark runner uses, so there is one image to maintain.
22+
Building needs a source checkout: a wheel installed with ``uv tool install``
23+
carries no Dockerfile. Outside a checkout the launcher uses an image that is
24+
already present, pulls an explicitly requested ``APODEX_IMAGE``, or builds
25+
from the checkout named by ``APODEX_BUILD_CONTEXT`` — and otherwise stops
26+
with the options spelled out rather than quietly running without the
27+
boundary the platform default promised.
1828
"""
1929
from __future__ import annotations
2030

@@ -24,12 +34,34 @@
2434
import shutil
2535
import subprocess
2636
import sys
27-
from collections.abc import Mapping
37+
from collections.abc import Mapping, Sequence
2838
from pathlib import Path
2939

3040
_DEFAULT_IMAGE = "apodex:local"
3141
IMAGE = os.environ.get("APODEX_IMAGE", _DEFAULT_IMAGE)
3242
_REPO_ROOT = Path(__file__).resolve().parents[1]
43+
BUILD_CONTEXT_VAR = "APODEX_BUILD_CONTEXT"
44+
45+
46+
class BuildContextUnavailable(RuntimeError):
47+
"""No directory with a Dockerfile to build the default image from."""
48+
49+
50+
def build_context(environ: Mapping[str, str] | None = None) -> Path | None:
51+
"""The directory whose ``Dockerfile`` builds :data:`_DEFAULT_IMAGE`.
52+
53+
``APODEX_BUILD_CONTEXT`` names it explicitly (a FrontierAgent checkout, for
54+
an installation that lives elsewhere). Otherwise it is this package's own
55+
repository root when that is a checkout. ``None`` for a wheel installed
56+
outside any checkout: ``site-packages`` has no Dockerfile.
57+
"""
58+
env = os.environ if environ is None else environ
59+
override = (env.get(BUILD_CONTEXT_VAR) or "").strip()
60+
if override:
61+
return Path(override).expanduser()
62+
if (_REPO_ROOT / "Dockerfile").is_file():
63+
return _REPO_ROOT
64+
return None
3365

3466

3567
def terminal_env(environ: Mapping[str, str]) -> list[str]:
@@ -122,21 +154,49 @@ def image_exists(image: str = IMAGE) -> bool:
122154
return probe.returncode == 0
123155

124156

125-
def build_image(image: str = IMAGE, *, quiet: bool = False) -> None:
157+
def build_image(
158+
image: str = IMAGE, *, quiet: bool = False, context: Path | None = None,
159+
) -> None:
126160
"""Build the image from the repo Dockerfile.
127161
128162
Streams the build output: it takes minutes the first time (LibreOffice and
129163
the document readers are large), and a silent multi-minute wait reads as a
130-
hang.
164+
hang. Raises :class:`BuildContextUnavailable` when there is no checkout
165+
to build from; the caller turns that into user-facing guidance.
131166
"""
132-
print(f"apodex: building {image} (first run only, this takes a few minutes)…",
133-
file=sys.stderr)
134-
cmd = ["docker", "build", "-t", image, str(_REPO_ROOT)]
167+
root = context if context is not None else build_context()
168+
if root is None:
169+
raise BuildContextUnavailable(
170+
f"{image} is not present locally, and this installation is not a "
171+
"source checkout, so it cannot be built here"
172+
)
173+
if not (root / "Dockerfile").is_file():
174+
raise BuildContextUnavailable(
175+
f"{BUILD_CONTEXT_VAR}={root} does not contain a Dockerfile"
176+
)
177+
print(f"apodex: building {image} from {root} (first run only, this takes a "
178+
"few minutes)…", file=sys.stderr)
179+
cmd = ["docker", "build", "-t", image, str(root)]
135180
if quiet:
136181
cmd.insert(2, "--quiet")
137182
subprocess.run(cmd, check=True)
138183

139184

185+
def _build_unavailable_message(reason: str) -> str:
186+
return (
187+
f"apodex: cannot use the Docker path — {reason}.\n"
188+
" Choose one:\n"
189+
" docker build -t apodex:local /path/to/FrontierAgent "
190+
"(build once from a checkout)\n"
191+
f" export {BUILD_CONTEXT_VAR}=/path/to/FrontierAgent "
192+
"(let this command build from it)\n"
193+
" export APODEX_IMAGE=<image you can pull> "
194+
"(use a registry image)\n"
195+
" frontier-agent --native … "
196+
"(workspace-local host runtime, not an OS sandbox)"
197+
)
198+
199+
140200
def pull_image(image: str) -> bool:
141201
"""Fetch *image* from its registry, returning whether it arrived.
142202
@@ -148,9 +208,19 @@ def pull_image(image: str) -> bool:
148208

149209

150210
def run_in_container(
151-
argv: list[str], *, cwd: str | None = None, image: str = IMAGE,
211+
argv: list[str],
212+
*,
213+
cwd: str | None = None,
214+
image: str = IMAGE,
215+
forward_env: Sequence[str] = (),
152216
) -> int:
153-
"""Re-exec ``apodex argv`` inside the container. Returns its exit code."""
217+
"""Re-exec ``apodex argv`` inside the container. Returns its exit code.
218+
219+
``forward_env`` names host variables to carry inward (``-e NAME``, value
220+
read by Docker from this process's environment). It is how an exported
221+
key, a launch-directory ``.env`` or the user env file reach a run that the
222+
checkout's ``--env-file`` alone would not cover.
223+
"""
154224
ok, why = docker_available()
155225
if not ok:
156226
print(
@@ -168,6 +238,9 @@ def run_in_container(
168238
if image == _DEFAULT_IMAGE:
169239
try:
170240
build_image(image)
241+
except BuildContextUnavailable as exc:
242+
print(_build_unavailable_message(str(exc)), file=sys.stderr)
243+
return 1
171244
except subprocess.CalledProcessError as exc:
172245
print(f"apodex: image build failed (exit {exc.returncode}).",
173246
file=sys.stderr)
@@ -274,6 +347,9 @@ def run_in_container(
274347
"-v", f"{home_state}:/root/.apodex",
275348
"-v", f"{home_config}:/root/.config/apodex",
276349
"-e", "APODEX_IN_CONTAINER=1",
350+
# The host already selected and resolved the user env file. The
351+
# mounted default config directory must not supply a second one.
352+
"-e", "APODEX_USER_ENV_RESOLVED=1",
277353
"-e", "HOME=/root",
278354
# Same contract as the Compose launchers: files written by the dropped-
279355
# privilege tool process keep useful host ownership. The entrypoint
@@ -312,6 +388,25 @@ def run_in_container(
312388
env_file = _REPO_ROOT / ".env"
313389
if env_file.is_file():
314390
docker_cmd += ["--env-file", str(env_file)]
391+
# Name only: ``-e NAME`` makes Docker read the value from this process's
392+
# environment, so the resolved key is never an argv token. Ordering after
393+
# --env-file is what lets the host-resolved value win over the checkout's
394+
# file, matching the exported-environment precedence native runs have.
395+
# Host files may also configure native execution. Preserve every explicit
396+
# container setting above, and keep host interpreter/config paths and the
397+
# higher-priority sandbox alias out of the container environment.
398+
reserved_names = {
399+
docker_cmd[index + 1].partition("=")[0]
400+
for index, arg in enumerate(docker_cmd[:-1]) if arg == "-e"
401+
}
402+
reserved_names.update({
403+
"APODEX_SANDBOX", "APODEX_ENV_FILE", "PATH", "PYTHONPATH",
404+
"PYTHONHOME", "VIRTUAL_ENV", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
405+
"XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "TMPDIR",
406+
})
407+
for name in dict.fromkeys(forward_env):
408+
if name in os.environ and name not in reserved_names:
409+
docker_cmd += ["-e", name]
315410
docker_cmd += [image, "apodex", *_without_cwd_arg(argv)]
316411

317412
try:

0 commit comments

Comments
 (0)