Install and build
Prerequisites
-
Rust nightly, at the pinned date.
rust-toolchain.tomlpins an exact nightly channel plus therustfmt,clippyandrust-srccomponents. Withrustupinstalled, the pin is applied automatically the first time you run a cargo command inside the repository, so there is nothing to select by hand. -
The
claudeCLI. forge spawns it; it does not bundle it or talk to the Anthropic API on its own. -
cargo-nextest, for the test suite.just checkuses it rather thancargo test. -
mdbook, for the book. CI pins0.5.4; rule 11’s workflow verifies a UI change withmdbook serve docs/book. -
just, if you want the task recipes. Most wrap a cargo invocation you can also type out. -
Node and npm, for the client.
just checkruns the client’s Prettier, ESLint,svelte-check,tscand vitest steps, and its android warnings check (client-android-warnings, the shell crate compiled foraarch64-linux-android), andjust client-tauri-checkbuilds the shell in its shipping configuration;just releasebuilds and installs it. CI uses Node 24, andnpm --prefix client cifetches the client’s dependencies. The client also builds for Android, which additionally wants the Android SDK, its NDK and a JDK 17 or newer; the recipe is inclient/README.md. -
A C and C++ toolchain,
cmake, andlibclang.forge-workspacedepends onforge-dictate, which builds two native model runtimes (transcribe-cpp-sys,llama-cpp-sys-2), and onforge-tts, whose Kokoro engine compiles bundled espeak-ng through CMake - so these are needed to build the binary at all, not only to run--workspace.cmakeand a C compiler were already required before that, throughaws-lc-sys; the C++ compiler andlibclang(forbindgen) are what the speech runtimes add.On macOS: Xcode Command Line Tools cover the compilers and
libclang, andcmakecomes from Homebrew. On Debian and Ubuntu:build-essential cmake libclang-dev. -
ALSA headers, on Linux only.
cpal’s Linux backend links against ALSA. On Debian and Ubuntu that islibasound2-dev; macOS needs no audio package.
About the nightly pin
The pin exists so every developer and CI build use the same compiler,
and CI enforces it rather than trusting it: the toolchain action reads
the channel out of rust-toolchain.toml, installs it, and then asserts
that the toolchain rustup will actually select matches the pin. That
guards against a RUSTUP_TOOLCHAIN in the environment or a rustup
override on the runner quietly moving CI to a different compiler while
the file still reads as authoritative.
Bump it deliberately, in its own change. No crate in the workspace
opts into an unstable language feature with #![feature(...)], so the
pin buys reproducibility rather than access to any particular nightly
feature. The published rustdoc is the one exception: it needs
-Z unstable-options --enable-index-page, which is nightly-only.
Development is macOS-first
forge is developed and run on macOS. Other platforms compile, and CI
builds and tests on Linux - with two jobs on macOS. One runs the
workspace clippy there, so the cfg(target_os = "macos") arms the Linux
clippy job compiles out are still linted; just check runs that same
clippy on whatever platform you run it on, so a macOS checkout lints them
locally too. The other compiles the two macOS-only halves no Linux runner
sees, the server’s browser host and the shell’s native overlay, and runs
the host’s own tests - but one thing behaves differently:
- App-level chords use Cmd on macOS, Ctrl elsewhere. The pane
toggles (
Cmd+Left/Cmd+RightvsCtrl+Left/Ctrl+Right) and toggle-all-tool-calls (Cmd+XvsCtrl+X) swap their modifier.
Build and check
just check
That runs four independent groups concurrently: the fast checks
(cargo fmt --check, the Unicode punctuation gate, the book’s client-page
word-count gate, and the scripts’ own tests, script-tests: the release
manifest’s, the client-dependency bootstrap’s, the client-quit script’s,
the check-group orchestrator’s,
the gate lock’s, the book’s client-page word-count test’s, the client-wasm
check’s, the browser-stack patch’s and the browser-host focus test’s), the
Rust workspace
(cargo clippy --all-targets --workspace -- -D warnings
once per feature set, with and without --all-features,
cargo nextest run --workspace --all-features,
cargo test --doc --workspace --all-features and
cargo doc --workspace --no-deps --all-features --document-private-items),
the client web steps (the client’s Prettier check, ESLint, svelte-check,
tsc --noEmit and then its vitest run), and the shell crate’s steps (its
rustfmt check client-tauri-fmt, its clippy and nextest run
client-tauri-clippy and client-tauri-test, and its android warnings
check client-android-warnings: cargo check --target aarch64-linux-android with warnings denied). The clippy, test,
doctest and doc steps each set RUSTFLAGS=-D warnings so a warning CI
would reject fails locally too; CI sets it once at workflow level
instead. It covers the build each step drives, and rustdoc compiles a
doctest on its own, so it does not deny warnings inside one.
FORGE_CHECK_JOBS caps how many of the four groups run at once; it
defaults to 4, so a machine with fewer cores sets it lower
(FORGE_CHECK_JOBS=2 just check). Every group runs to its own first
failure, each failing group’s log tail is printed, and the verdict names
the first failing step in the gate’s order, so a concurrent group cannot
hide one. The one verdict with no step behind it is the gate lock’s:
[ERROR] check: the gate lock was not acquired (held by pid N), printed
when another gate on the machine held the lock past the wait bound,
before any step runs. Each run’s per-step logs stay in their own
target/check-logs/run.XXXXXX/ directory, printed as logs: ... before
the verdict.
just check-serial is the same gate one step at a time, for debugging a
step or running on a machine that should not carry the groups together.
When sccache is on PATH, just exports RUSTC_WRAPPER=sccache, so
compiled objects are shared across worktrees and branches; without it the
recipes build normally. It is never required.
The gate is two commands: run this one before opening a pull request, and
just check-feature-configs with it. just check runs with
--all-features, so it deliberately leaves out the configuration
install.sh builds; the second command is what compiles it. Beyond that
this is CI’s set with four differences: CI also runs
cargo check --release, the client’s production build with
scripts/check_client_wasm.py (the wasm the highlighter imports, which
just check never builds), and scripts/install.sh followed by a boot of
the installed binary with no library path (the shared transcribe/ggml
libraries have to resolve from beside it, and neither local command
installs anything), while client-android-warnings (the shell’s android
step) is in just check alone - the vendoring its build needs refuses
non-macOS hosts, so CI runs no such job yet (issue #1959, closed as an
enhancement and tracked in #2258). The
client’s steps are in here too, so the client and the server are not
separate commands, and a failure on either side lands on the same verdict
line.
CI runs each build and test job in ci.yml only for the paths it serves,
so a docs-only pull request does not wait for the Rust or client jobs. The
macOS clippy job serves the same paths as the Linux one, because it lints
the same source on the second platform. The client’s CEF job serves every
crate in forge-server’s dependency graph -
forge-browser, forge-server, forge-workspace, forge-agent,
forge-gateway, forge-connectors, forge-dictate, forge-tts,
forge-terminal, forge-capture, forge-system-one, forge-supervise,
forge-sdk and forge-primitives -
and the inputs
that resolve or build them: Cargo.lock, the root Cargo.toml,
rust-toolchain.toml, clippy.toml, scripts/ and the nextest config.
One job, ci-required,
always runs, depends on every job in that workflow, and fails when a job
that ran failed or when a job whose paths changed was skipped. The ruleset
requires two checks: ci-required there, and build site from the docs
workflow, which runs on every pull request. A skipped job means its paths
did not move, never that the gate passed without looking.
The shell under client/src-tauri/ is its own workspace root, so the
workspace’s cargo steps do not reach it; in CI its clippy and tests are
the client-tauri job’s, and its rustfmt check the cargo fmt --check
job’s second invocation. just check reaches it through
client-tauri-fmt (the shell crate’s own cargo fmt --check), the
crate’s own clippy and tests (client-tauri-clippy and
client-tauri-test) and
client-android-warnings - the crate compiled
for aarch64-linux-android with warnings denied, which is what catches
desktop-only code going dead on Android - and the Unicode punctuation gate
and the client’s Prettier step reach it too. The shell’s shipping
configuration needs just client-tauri-check (which runs the tests too)
or just client-tauri-bundle (plus the bundles), and the first is the one
to run before handing over a change there.
A client bundles with just client-tauri-bundle, and carries no browser:
the engine, its driver and every profile live on the SERVER. The install
(scripts/install.sh) is what stages the browser there - a signed
Forge Browser.app beside forge’s machine-local state, and the driver
stack (node and @playwright/mcp, about 140 MB) beside it. The vendoring
needs network access the first time and nothing after that, and prints the
pins it verified on every run. The server’s browser is macOS-only: the
install stages the bundle only on Darwin, and elsewhere its browser tools
answer the no-host refusal by name. No browser needs installing: a
client built where the server has no engine answers the browser doors with
the server’s own sentence rather than a timeout.
The run ends on a verdict line naming its own result: [OK] check: ...,
[ERROR] check: <step> failed, [ERROR] check: group reported nothing (<group>) when a group died without a report, or
[ERROR] check: the gate lock was not acquired (held by pid N) when
another gate on the machine held the lock past the wait bound. A failing
group stops at its own first failure; the other groups still run; every
failing group’s log tail is printed before the verdict; and when a step
fails, the verdict names it and the later steps in that group that did not
run. The lock verdict is the one with no step behind it: it arrives before
any step runs. That
line is deliberately the last one written: piping the output through
tail reports tail’s exit status rather than the recipe’s, so a red gate
reads green to whoever ran it unless the result travels in the text.
Individual pieces, if you want a faster loop:
just fmt # rewrite files to match rustfmt
just clippy # lints only
just test-all # whole workspace, all features
just test # forge-sdk only
just conformance # replay every committed wire baseline
just doctest # compile the workspace's doctests
just doc # rustdoc with warnings denied
just check-delta <crate> # fmt, clippy and nextest for the named crates
just check-serial # the whole gate, one step at a time
just client-tauri-clippy # the shell crate's lints
just client-tauri-test # the shell crate's tests
just client-tauri-check # the client shell, in the shipping configuration
just client-android-check # the Kotlin half and its unit tests
just web-image-check # the web image's poller, entrypoint and serving tests
just check-delta <crate>... is the fast slice for a review round: each
named workspace crate’s rustfmt check, both clippy feature sets and its
nextest run, the Unicode gate, and the client’s steps or the shell crate’s
clippy and tests when client or client-tauri is named. It is not the
pre-merge gate: the final head still runs the full just check and
just check-feature-configs whole.
just check-release compiles the workspace in release mode. It is
deliberately not part of just check, because a second full compile is
too slow for the inner loop; it catches the errors only a release build
sees.
just check-feature-configs compiles the configurations nothing else
builds: the one just install produces - release, the test-only features
off, the server binary plus the host executable install.sh builds
separately - and testing on its own. CI goes one step further and
installs the server binary of that configuration, booting it with no
library path to prove the shared transcribe/ggml libraries resolve beside
it; the local recipe stops at compiling.
--all-features cannot stand in for either, because it turns every
feature on regardless and so never exercises which configuration
enables what: the install build leaving test-helpers off, or
testing having to forward it.
just release gates on both.
Install the binary
just install
This builds forge from the current working tree and installs the
binary into cargo’s install root, which is ~/.cargo/bin/forge
unless you have overridden CARGO_INSTALL_ROOT or CARGO_HOME. It
builds whatever branch is checked out; it does not clone, fetch or
reset.
It also ships transcribe’s shared libraries beside the binary. The 0.4
ggml builds as a shared library, because its static copy duplicates
llama-cpp-2’s symbols in one Linux binary, and the loader does not search
the executable’s own directory: forge carries a loader path naming that
directory, and the libtranscribe and libggml* files land beside it. No
environment variable is involved. A hand-rolled cargo install copies only
the binary and leaves one that cannot start.
The install builds into a staging root and replaces the installed binary only once the staged copy starts with its libraries beside it; a failure before the replacement leaves the previous binary untouched.
One side effect worth knowing about: it regenerates the zsh completion
at ~/.zsh/completions/_forge (override the directory with
FORGE_ZSH_COMPLETION_DIR) and removes ~/.zcompdump* so the next
shell picks it up. On macOS it also stages the browser host and its
driver; FORGE_INSTALL_BROWSER=0 skips that stage.
just release <version> installs this binary too, and publishes the
release. It bumps the workspace and the client’s manifest to the same
number, commits and tags them, then installs the server binary through
this same script, builds and installs the client over
/Applications/forge.app, and stages the Android release APK and the web
archive. Only then does it push main with the tag and create the GitHub
release with every asset the update path reads; the push and the publish
are last because they are the only irreversible steps. The binary goes
first, so a failure later in the sequence - a client that does not quit
when the release asks it to, a missing Android keystore or toolchain -
cannot leave it stale. The install replaces the on-disk binary; a
running forge picks the new one up at its next restart. A failure after
the tag aborts the release with the tag cut; each half re-runs alone with
the tree still at the tag (just install, just client-release <version>,
just client-android-release <version>, just client-web-release <version>), and a release whose tag was cut and pushed finishes with
just publish <version>, while re-running just release refuses
because the tag exists.
First run
forge needs a forge.toml before it can start. Create
<config_dir>/forge/forge.toml with at least one org containing one
project, and at least one account. The
configuration reference has a complete example.
forge # reopen the projects left open
forge <PROJECT> # open a specific project by its forge.toml name
Useful flags:
| Flag | Effect |
|---|---|
--new | Start every boot session fresh instead of resuming. Only affects the startup wave. |
--log-file <PATH> | Write tracing diagnostics to a file. |
--log-filter <FILTER> | Tracing filter directives, for example info,bridge.sdk=trace. Overrides --diagnostics-preset, and falls back to RUST_LOG when omitted. |
--diagnostics-preset <NAME> | A named logging preset: runtime, session, bridge or full. Ignored when --log-filter is given. |