Skip to content

Build the CLI from source

This page is for contributors: it takes a fresh clone of the GAIA repository to a working gaia on your PATH, with no container or worktree prerequisite.

You probably don't need this

If you just want to use GAIA, install the published CLI instead — no clone, no pnpm, no build:

sh
npm install -g @gaia-ai/gaia

npm resolves the whole package set for you. See Install the CLI. Building from source is only for working on the CLI.

Prerequisites

  • Node.js ≥ 20.19 (the engines floor of every package in the workspace).

  • pnpm — the workspace package manager. If it is missing:

    sh
    corepack enable pnpm        # ships with Node >= 16.9 (recommended)
    npm install -g pnpm

The bootstrap script checks for both and prints an actionable message if either is missing.

Bootstrap a fresh clone

From the repository root:

sh
gaia-cli/scripts/setup.sh

or, equivalently, from the gaia-cli/ directory:

sh
cd gaia-cli && pnpm run bootstrap

The script is idempotent — re-run it any time, for instance after pulling changes that touch any package or addon.

When it finishes you have a gaia on your PATH backed by your checkout's build. To point it at a control plane and log in, follow Install the CLI — connecting is the same for a source build as for a global install.

What the bootstrap does

gaia-cli/scripts/setup.sh resolves the repository root from its own path (it does not depend on git) and runs eight idempotent steps.

1. Prerequisite check — verifies pnpm is on PATH and sanity-checks Node ≥ 20.19, exiting non-zero with an actionable message if not.

2. Installpnpm install --config.minimumReleaseAge=0 in gaia-cli/. The minimumReleaseAge=0 dodges the workspace's supply-chain deps-verify policy, which would otherwise fail a plain install on a fresh tree.

3. Build the packages and addons — in layered dependency order, each step emitting the .d.ts files the next one consumes:

  1. core — the surface-agnostic kernel, with no @gaia-ai/* edge of its own;

  2. conductor — the run engine, which owns the conductor-surface contract (@gaia-ai/conductor/contract) that every conductor-surface addon imports, so it must precede the addons;

  3. addons/* — with workspace-git forced first, because addon-herdr takes loadInstructions from it (the one permitted addon-to-addon edge);

  4. ui — imports @gaia-ai/addon-herdr;

  5. host — the leaf sink; imports @gaia-ai/ui/preset plus every addon preset.

    The script invokes tsc directly rather than pnpm run build, to stay fresh-tree-safe.

4. Resolution farm — creates a repository-root node_modules/ symlink farm so a .gaia/conductor.config.js can resolve its bare imports. Node's ESM resolver walks up from .gaia/ looking for @gaia-ai/gaia, @gaia-ai/addon-* and @dropsh/*; the root farm is where it lands. Each package is linked under its real package.json name, not a directory-derived one — @gaia-ai/ui lives beside addons/, while addons/gaia-ui is @gaia-ai/addon-gaia-ui:

ImportSymlink target
@gaia-ai/coregaia-cli/core/
@gaia-ai/conductorgaia-cli/conductor/
@gaia-ai/gaiagaia-cli/host/
@gaia-ai/uigaia-cli/ui/
@gaia-ai/addon-<name>gaia-cli/addons/<name>/
@dropsh/<pkg>gaia-cli/node_modules/@dropsh/<pkg>/

The farm lives under the already-gitignored node_modules/, so it never dirties the tree.

5. PATH shim — installs a test-workspace-aware gaia to ~/.local/bin/gaia (see below). If a differing gaia already sits there it is left untouched — the script warns and skips, and never clobbers; the warning prints the one command that adopts the new one (see Adopting a new shim below). If ~/.local/bin is not on your PATH it prints the export line to add; if a global gaia sits ahead of ~/.local/bin on PATH, shadowing the shim, it warns about that too.

6. Config scaffold — writes .gaia/conductor.config.local.js from gaia-cli/templates/conductor.config.local.js, leaving an existing file untouched. Interactive shells are prompted for machine_id (default: the short hostname) and oauth.client_id (default: gaia-agent); non-interactive shells take the defaults. No secret is ever written — the scaffold reads client_secret from process.env.GAIA_CONSUMER_SECRET.

7. herdr run-session plugins — links the repository-local spiceedit herdr plugin that powers the review click-to-edit overlay. Best-effort and guarded: skipped when herdr is not on PATH (it is an executor, not a CLI prerequisite), and a missing spiceedit binary only prints a note. Nothing here can fail the bootstrap.

8. Summary — prints the remaining steps to connect and authenticate.

Monorepo layout

gaia-cli/
├── core/       # @gaia-ai/core      — the surface-AGNOSTIC kernel: host contract, split-config
│               #                      helpers, conductor registry, utils, workflow step-contract,
│               #                      and the generic preset/discovery machinery. No plugin
│               #                      implementations, no conductor-surface interfaces, no
│               #                      config contract. Zero @gaia-ai/* edges.
├── conductor/  # @gaia-ai/conductor — the run engine + the `conductor` command plugin + the
│               #                      CONDUCTOR-SURFACE CONTRACT and the config contract
│               #                      (`@gaia-ai/conductor/contract`)
├── ui/         # @gaia-ai/ui        — the project-first `gaia ui` cockpit command app
├── host/       # @gaia-ai/gaia      — the plugin HOST (`bin gaia`) + `gaia upgrade`; the leaf sink
├── addons/*    # @gaia-ai/addon-*   — every mountable, one package per addon
├── scripts/    # setup.sh, prepare-publish.mjs, release.mjs, verify-package.mjs, assert-acyclic.mjs
└── templates/  # conductor.config.js, gaia.config.js, conductor.config.local.js, gaia.pathshim.sh

gaia-cli/ itself is a private workspace root and is never published. Its pnpm workspace globs are core, host, conductor, ui, addons/*.

17 packages are published: @gaia-ai/core, @gaia-ai/conductor, @gaia-ai/ui, @gaia-ai/gaia, plus 13 @gaia-ai/addon-* packages — auth-basic, claude, codex, deployment, dropsh, essentials, gaia-ui, herdr, kimi, opencode, pi, remote-drupal, workspace-git. There is a 14th addon directory, addons/fake (@gaia-ai/addon-fake), which is workspace-only: a test fixture, never published.

Addons and plugins

Two levels, and the distinction matters when you read a config or a preset:

  • An addon is a package under addons/*. It self-declares its contributions through a fixed ./preset export, and it is what you list in an addons: [] config array. One addon may ship several plugins — @gaia-ai/addon-herdr ships an executor and a workspace.
  • A plugin is the single typed per-surface contribution: RemotePlugin, ExecutorPlugin, WorkspacePlugin, AgentPlugin (conductor surface), GaiaCommandPlugin (command surface), DropSHPlugin / connectionPlugins (connection surface).

The *Plugin interface names and the /plugins subpath barrels keep the word "plugins"; only the package prefix is @gaia-ai/addon-.

Writing an addon

Peer-depend on @gaia-ai/core and — if the addon contributes to the conductor surface — on @gaia-ai/conductor too, then import the interfaces from the runtime-light subpath:

ts
import type { ExecutorPlugin, GaiaExecutor } from '@gaia-ai/conductor/contract';

@gaia-ai/conductor/contract carries only the surface interfaces, the config contract, the preset view and two tiny helpers — no engine, no commander, no dropsh runtime — so importing it costs nothing at boot, unlike the package main.

Export the addon's contributions from src/preset.ts behind the package's ./preset export. gaia-cli/scripts/assert-acyclic.mjs reads those accumulator exports to decide which peer dependencies the addon owes — a name-pattern-free ratchet, so a conductor-surface addon that forgets its @gaia-ai/conductor peer fails the guard. That script also enforces the layering: the kernel has zero @gaia-ai/* edges, nothing depends on the @gaia-ai/gaia sink, and an addon's edge set stays within {core, conductor, addon-dropsh} plus the single permitted sibling edge.

The gaia on your PATH

There is a single gaia on your PATH — no separate gaia-dev. That one gaia is a test-workspace-aware shim, backed by two thin launchers with different jobs:

  • ~/.local/bin/gaia — the shim installed by step 5 (canonical source: gaia-cli/templates/gaia.pathshim.sh), placed ahead of the npm global on PATH. It resolves in two tiers (GAIA-390): inside a skill-test workspace — recognised by a .gaia-testbuild marker file at or above the physical working directory, written by scripts/setup-test-project.sh and naming the checkout that created the workspace — it runs that checkout's built gaia-cli/bin/gaia, and a marker naming an unbuilt checkout fails loudly with a build hint rather than falling through to a different gaia; everywhere else — inside a plain gaia checkout included — it runs the globally-installed @gaia-ai/gaia. So a bare gaia <cmd> is the released CLI unless you stand in the one place a branch build is under test.

    The lookup walks the filesystem, not git history: the conductor's per-ticket worktrees live inside the workspace, where a git-based lookup would stop at the worktree's own toplevel and never see the marker.

  • gaia-cli/bin/gaia — the dev launcher. It runs the built plugin host (host/dist/src/host.js), which lazily mounts only the invoked command plugin (conductor, ui, dropsh or deployment) — so it needs the packages built by step 3. The shim execs it inside a test workspace; anywhere else, call ./gaia-cli/bin/gaia by explicit path to exercise a checkout's own build. Tier 1 used to key on the git toplevel, which meant an unbuilt or broken branch build broke every unrelated gaia command in that worktree.

Adopting a new shim

Step 5 never overwrites a differing ~/.local/bin/gaia, so a changed template does not reach an existing install by re-running setup.sh. Adopt it explicitly — the WARN prints this line for you:

bash
cp gaia-cli/templates/gaia.pathshim.sh ~/.local/bin/gaia && chmod 0755 ~/.local/bin/gaia

Diff first if you have edited yours: diff ~/.local/bin/gaia gaia-cli/templates/gaia.pathshim.sh.

PATH-order hazard

The shim only wins if ~/.local/bin precedes the active Node's bin directory — where npm install -g @gaia-ai/gaia lands — on PATH. If the global bin comes first, the released CLI shadows the shim and a bare gaia in a test workspace never sees the build under test. Step 5 detects and warns about this ordering; fix it with:

sh
export PATH="$HOME/.local/bin:$PATH"

.gaia/conductor.config.local.js

A checkout's .gaia/conductor.config.js is committed. It loads an optional per-developer override, .gaia/conductor.config.local.js, which is gitignored and read only if present. The scaffold carries only non-secret fields:

FieldPurpose
machine_idthis node's identifier
oauth.client_idthe OAuth consumer (default gaia-agent)
oauth.client_secretread from process.env.GAIA_CONSUMER_SECRETnever committed

Export the secret before running the conductor:

sh
export GAIA_CONSUMER_SECRET=<your consumer secret>

The config model itself — the connection config gaia.config.js versus the engine config conductor.config.js — is documented in Connection config and Engine config.

Rebuild after editing sources

The CLI runs the compiled dist/, so an edit is invisible until you rebuild:

sh
cd gaia-cli && pnpm install && pnpm -r run build

pnpm run build:all is an alias for the same thing. Or just re-run gaia-cli/scripts/setup.sh — it is idempotent, refreshes the resolution farm and the shim too, and builds in the layered order core → conductor → addons → ui → host, which a bare pnpm -r does not guarantee on a fresh tree.

Check the publish shape locally

Before a release, reproduce what npm consumers will actually get:

sh
cd gaia-cli && pnpm run verify:package

It builds every package, stages a fresh publish manifest for all 17, npm packs each one, and installs the whole set into a throwaway project with plain npm install — no pnpm, no workspace links. Then it asserts that:

  • no published manifest ships a scripts block;
  • gaia --help works and lists all four command plugins (conductor, ui, dropsh, deployment) as lazy stubs, importing none of them;
  • gaia deployment tickets --help really mounts from @gaia-ai/addon-deployment;
  • an engine conductor.config.js resolves its remote, executor, agent and workspace slots through loadConductorConfig, with the Drupal remote resolving from @gaia-ai/addon-remote-drupal in the addons: [] form;
  • a connection gaia.config.js resolves its site and plugins[] through loadGaiaConfig.

Publish manifests are generated, never hand-maintained: gaia-cli/scripts/prepare-publish.mjs writes a fresh manifest per package into <package>/publish/, rewriting every workspace:^ dependency to the release version. The monorepo's own workspace: wiring is untouched. Its target list is the publish order, and the release script iterates the same list.

Troubleshooting the build

  • ERR_MODULE_NOT_FOUND: Cannot find package '@gaia-ai/gaia' (or @gaia-ai/addon-*, or @dropsh/*) when a command loads its config → the resolution farm or an addon build is missing or stale. Re-run gaia-cli/scripts/setup.sh.
  • gaia: command not found~/.local/bin is not on your PATH. Add it: export PATH="$HOME/.local/bin:$PATH".
  • gaia: test workspace … names checkout …, which is not built. → exactly what it says, and deliberately fatal rather than a silent fallback to the global CLI: the workspace is the one place whose purpose is to run that build. Run the rebuild the message prints.
  • gaia: no global gaia on PATH, and no .gaia-testbuild workspace above … → you are outside every test workspace and have no global gaia. Install the published CLI, or call a checkout's ./gaia-cli/bin/gaia by explicit path.
  • The shim runs the global CLI inside a gaia checkout → that is the rule since GAIA-390, not a fault. A checkout's own build is selected only inside a .gaia-testbuild workspace; use ./gaia-cli/bin/gaia to exercise it from the checkout. If a bare gaia in a test workspace is the global CLI, your installed shim predates GAIA-390 — adopt the current one (see Adopting a new shim above).

For anything that is not a build problem — connecting, authenticating, migrating configs — see Install the CLI and Set up a project.