Skip to content

build

build

Container image building and build-context staging.

Owns the L0 (base dev) and L1 (agent CLI) Dockerfile templates, resource staging, image naming, and podman build invocation.

Image layer architecture::

L0  (base)   — Ubuntu + dev tools + init script + dev user
L1  (agent)  — All AI agent CLIs, shell environment, ACP wrappers
               L1 is self-sufficient for standalone use — all user
               config (repo URL, SSH, branch, gate) is runtime.
─── boundary: above owned by terok-agent, below by terok ───
L2  (project)— Optional: user Dockerfile snippet (custom packages)
               Only built when project has docker snippet config.

terok-agent run claude . launches directly on the L1 image — no L2 build needed. terok adds L2 only for project-specific image customisation.

Usage as a library::

from terok_agent.build import build_base_images

images = build_base_images("ubuntu:24.04")
# images.l0 = "terok-l0:ubuntu-24.04"
# images.l1 = "terok-l1-cli:ubuntu-24.04"

The templates currently use Dockerfile ARG/${VAR} syntax. Jinja2 is wired in for future use — converting L1 install blocks to registry-driven {% for agent %} loops.

DEFAULT_BASE_IMAGE = 'ubuntu:24.04' module-attribute

Default base OS image when none is specified.

BuildError

Bases: RuntimeError

Raised when base-image construction cannot complete.

The CLI maps this to a user-facing error message; library callers can catch it without being terminated by SystemExit.

ImageSet(l0, l1, l1_sidecar=None) dataclass

L0 + L1 image tags produced by a build.

l0 instance-attribute

L0 base dev image tag (e.g. terok-l0:ubuntu-24.04).

l1 instance-attribute

L1 agent CLI image tag (e.g. terok-l1-cli:ubuntu-24.04).

l1_sidecar = None class-attribute instance-attribute

L1 sidecar image tag, if built (e.g. terok-l1-sidecar:ubuntu-24.04).

l0_image_tag(base_image)

Return the L0 base dev image tag for base_image.

Source code in src/terok_agent/build.py
def l0_image_tag(base_image: str) -> str:
    """Return the L0 base dev image tag for *base_image*."""
    return f"terok-l0:{_base_tag(base_image)}"

l1_image_tag(base_image)

Return the L1 agent CLI image tag for base_image.

Source code in src/terok_agent/build.py
def l1_image_tag(base_image: str) -> str:
    """Return the L1 agent CLI image tag for *base_image*."""
    return f"terok-l1-cli:{_base_tag(base_image)}"

l1_sidecar_image_tag(base_image)

Return the L1 sidecar (tool-only) image tag for base_image.

Source code in src/terok_agent/build.py
def l1_sidecar_image_tag(base_image: str) -> str:
    """Return the L1 sidecar (tool-only) image tag for *base_image*."""
    return f"terok-l1-sidecar:{_base_tag(base_image)}"

stage_scripts(dest)

Stage container helper scripts into dest.

Copies all files from terok_agent/resources/scripts/ into the given directory, replacing any existing contents. Python bytecode caches and __init__.py markers are excluded.

Source code in src/terok_agent/build.py
def stage_scripts(dest: Path) -> None:
    """Stage container helper scripts into *dest*.

    Copies all files from ``terok_agent/resources/scripts/`` into the given
    directory, replacing any existing contents.  Python bytecode caches and
    ``__init__.py`` markers are excluded.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_agent", "resources/scripts", dest)
    _clean_packaging_artifacts(dest)

stage_toad_agents(dest)

Stage Toad ACP agent TOML definitions into dest.

These describe OpenCode-based agents (Blablador, KISSKI, etc.) that are injected into Toad's bundled agent directory at container build time.

Source code in src/terok_agent/build.py
def stage_toad_agents(dest: Path) -> None:
    """Stage Toad ACP agent TOML definitions into *dest*.

    These describe OpenCode-based agents (Blablador, KISSKI, etc.) that are
    injected into Toad's bundled agent directory at container build time.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_agent", "resources/toad-agents", dest)
    _clean_packaging_artifacts(dest)

stage_tmux_config(dest)

Stage the container tmux configuration into dest.

Copies container-tmux.conf — the green-status-bar config that distinguishes container tmux sessions from host tmux.

Source code in src/terok_agent/build.py
def stage_tmux_config(dest: Path) -> None:
    """Stage the container tmux configuration into *dest*.

    Copies ``container-tmux.conf`` — the green-status-bar config that
    distinguishes container tmux sessions from host tmux.
    """
    if dest.exists():
        shutil.rmtree(dest)
    _copy_package_tree("terok_agent", "resources/tmux", dest)
    _clean_packaging_artifacts(dest)

render_l0(base_image=DEFAULT_BASE_IMAGE)

Render the L0 (base dev) Dockerfile.

The base_image is normalised before rendering so that blank or whitespace-only values produce a valid Dockerfile.

Source code in src/terok_agent/build.py
def render_l0(base_image: str = DEFAULT_BASE_IMAGE) -> str:
    """Render the L0 (base dev) Dockerfile.

    The *base_image* is normalised before rendering so that blank or
    whitespace-only values produce a valid Dockerfile.
    """
    return _render_template(
        "l0.dev.Dockerfile.template",
        {"BASE_IMAGE": _normalize_base_image(base_image)},
    )

render_l1(l0_image, *, cache_bust='0')

Render the L1 (agent CLI) Dockerfile.

l0_image is the tag of the L0 image to build on top of. cache_bust invalidates the agent-install layers when changed (typically set to a Unix timestamp).

Source code in src/terok_agent/build.py
def render_l1(l0_image: str, *, cache_bust: str = "0") -> str:
    """Render the L1 (agent CLI) Dockerfile.

    *l0_image* is the tag of the L0 image to build on top of.
    *cache_bust* invalidates the agent-install layers when changed
    (typically set to a Unix timestamp).
    """
    return _render_template(
        "l1.agent-cli.Dockerfile.template",
        {"BASE_IMAGE": l0_image, "AGENT_CACHE_BUST": cache_bust},
    )

render_l1_sidecar(l0_image, *, tool_name='coderabbit', cache_bust='0')

Render the L1 sidecar (tool-only) Dockerfile.

The sidecar image is built FROM L0 (not L1) and installs a single tool binary — no agent CLIs, no LLMs. The tool_name selects which tool install block to activate via Jinja2 conditional.

Source code in src/terok_agent/build.py
def render_l1_sidecar(
    l0_image: str, *, tool_name: str = "coderabbit", cache_bust: str = "0"
) -> str:
    """Render the L1 sidecar (tool-only) Dockerfile.

    The sidecar image is built FROM L0 (not L1) and installs a single
    tool binary — no agent CLIs, no LLMs.  The *tool_name* selects which
    tool install block to activate via Jinja2 conditional.
    """
    return _render_template(
        "l1.sidecar.Dockerfile.template",
        {"BASE_IMAGE": l0_image, "TOOL_CACHE_BUST": cache_bust, "tool_name": tool_name},
    )

prepare_build_context(dest)

Stage auxiliary resources into a build context directory.

After calling this, dest contains the resources that Dockerfile COPY directives reference:

  • scripts/ — container helper scripts (init, env, ACP wrappers)
  • toad-agents/ — ACP agent TOML definitions
  • tmux/ — container tmux config

Dockerfiles themselves are not written here — they are rendered and placed by :func:build_base_images (which calls this function internally).

Source code in src/terok_agent/build.py
def prepare_build_context(dest: Path) -> None:
    """Stage auxiliary resources into a build context directory.

    After calling this, *dest* contains the resources that Dockerfile
    ``COPY`` directives reference:

    - ``scripts/``     — container helper scripts (init, env, ACP wrappers)
    - ``toad-agents/`` — ACP agent TOML definitions
    - ``tmux/``        — container tmux config

    Dockerfiles themselves are **not** written here — they are rendered
    and placed by :func:`build_base_images` (which calls this function
    internally).
    """
    dest.mkdir(parents=True, exist_ok=True)
    stage_scripts(dest / "scripts")
    stage_toad_agents(dest / "toad-agents")
    stage_tmux_config(dest / "tmux")

build_base_images(base_image=DEFAULT_BASE_IMAGE, *, rebuild=False, full_rebuild=False, build_dir=None)

Build L0 + L1 container images and return their tags.

Skips building if images already exist locally (unless rebuild or full_rebuild is set). Uses a temporary directory for the build context by default; pass build_dir to use a specific (empty or non-existent) directory instead.

Parameters:

Name Type Description Default
base_image str

Base OS image (e.g. ubuntu:24.04, nvidia/cuda:...).

DEFAULT_BASE_IMAGE
rebuild bool

Force rebuild with cache bust (refreshes agent installs).

False
full_rebuild bool

Force rebuild with --no-cache --pull=always.

False
build_dir Path | None

Build context directory (must be empty or absent).

None

Returns:

Type Description
ImageSet

class:ImageSet with the L0 and L1 image tags.

Raises:

Type Description
BuildError

If podman is missing or a build step fails.

ValueError

If build_dir exists and is non-empty.

Source code in src/terok_agent/build.py
def build_base_images(
    base_image: str = DEFAULT_BASE_IMAGE,
    *,
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
) -> ImageSet:
    """Build L0 + L1 container images and return their tags.

    Skips building if images already exist locally (unless *rebuild* or
    *full_rebuild* is set).  Uses a temporary directory for the build
    context by default; pass *build_dir* to use a specific (empty or
    non-existent) directory instead.

    Args:
        base_image: Base OS image (e.g. ``ubuntu:24.04``, ``nvidia/cuda:...``).
        rebuild: Force rebuild with cache bust (refreshes agent installs).
        full_rebuild: Force rebuild with ``--no-cache --pull=always``.
        build_dir: Build context directory (must be empty or absent).

    Returns:
        :class:`ImageSet` with the L0 and L1 image tags.

    Raises:
        BuildError: If podman is missing or a build step fails.
        ValueError: If *build_dir* exists and is non-empty.
    """
    # Validate arguments before any side effects (podman probe, temp dirs)
    if build_dir is not None:
        if build_dir.is_file():
            raise ValueError(f"build_dir is a file, not a directory: {build_dir}")
        if build_dir.exists() and any(build_dir.iterdir()):
            raise ValueError(f"build_dir must be empty or absent: {build_dir}")

    _check_podman()

    base_image = _normalize_base_image(base_image)
    l0_tag = l0_image_tag(base_image)
    l1_tag = l1_image_tag(base_image)

    # Skip if both images exist and no forced rebuild
    if not rebuild and not full_rebuild:
        if _image_exists(l0_tag) and _image_exists(l1_tag):
            return ImageSet(l0=l0_tag, l1=l1_tag)

    # Prepare build context in a safe directory
    import tempfile

    own_tmp = build_dir is None
    context = build_dir or Path(tempfile.mkdtemp(prefix="terok-agent-build-"))

    try:
        prepare_build_context(context)

        # Single timestamp for both render and build-arg consistency
        cache_bust = str(int(time.time()))

        # Render and write Dockerfiles into the build context
        (context / "L0.Dockerfile").write_text(render_l0(base_image))
        (context / "L1.cli.Dockerfile").write_text(render_l1(l0_tag, cache_bust=cache_bust))

        ctx = str(context)

        # Build L0 — base dev image (Ubuntu + git + SSH + init script)
        cmd_l0 = ["podman", "build", "-f", str(context / "L0.Dockerfile")]
        cmd_l0 += ["--build-arg", f"BASE_IMAGE={base_image}"]
        cmd_l0 += ["-t", l0_tag]
        if full_rebuild:
            cmd_l0 += ["--no-cache", "--pull=always"]
        cmd_l0.append(ctx)

        print("$", shlex.join(cmd_l0))
        subprocess.run(cmd_l0, check=True)

        # Build L1 — agent CLI layer (all agent installs, shell env, ACP wrappers)
        cmd_l1 = ["podman", "build", "-f", str(context / "L1.cli.Dockerfile")]
        cmd_l1 += ["--build-arg", f"BASE_IMAGE={l0_tag}"]
        cmd_l1 += ["--build-arg", f"AGENT_CACHE_BUST={cache_bust}"]
        cmd_l1 += ["-t", l1_tag]
        if full_rebuild:
            cmd_l1.append("--no-cache")
        cmd_l1.append(ctx)

        print("$", shlex.join(cmd_l1))
        subprocess.run(cmd_l1, check=True)

    except (OSError, subprocess.CalledProcessError) as e:
        raise BuildError(f"Image build failed: {e}") from e
    finally:
        if own_tmp:
            shutil.rmtree(context, ignore_errors=True)

    return ImageSet(l0=l0_tag, l1=l1_tag)

build_sidecar_image(base_image=DEFAULT_BASE_IMAGE, *, tool_name='coderabbit', rebuild=False, full_rebuild=False, build_dir=None)

Build the L1 sidecar image for a specific tool. Returns the image tag.

Ensures L0 exists first (builds it if missing), then builds the sidecar image FROM L0. The sidecar contains only the named tool — no agent CLIs, no LLMs.

Parameters:

Name Type Description Default
base_image str

Base OS image (passed through to L0 build).

DEFAULT_BASE_IMAGE
tool_name str

Tool to install (selects Jinja2 conditional in template).

'coderabbit'
rebuild bool

Force rebuild with cache bust.

False
full_rebuild bool

Force rebuild with --no-cache.

False
build_dir Path | None

Build context directory (must be empty or absent).

None

Returns:

Type Description
str

The sidecar image tag (e.g. terok-l1-sidecar:ubuntu-24.04).

Raises:

Type Description
BuildError

If podman is missing or a build step fails.

Source code in src/terok_agent/build.py
def build_sidecar_image(
    base_image: str = DEFAULT_BASE_IMAGE,
    *,
    tool_name: str = "coderabbit",
    rebuild: bool = False,
    full_rebuild: bool = False,
    build_dir: Path | None = None,
) -> str:
    """Build the L1 sidecar image for a specific tool. Returns the image tag.

    Ensures L0 exists first (builds it if missing), then builds the
    sidecar image FROM L0.  The sidecar contains only the named tool —
    no agent CLIs, no LLMs.

    Args:
        base_image: Base OS image (passed through to L0 build).
        tool_name: Tool to install (selects Jinja2 conditional in template).
        rebuild: Force rebuild with cache bust.
        full_rebuild: Force rebuild with ``--no-cache``.
        build_dir: Build context directory (must be empty or absent).

    Returns:
        The sidecar image tag (e.g. ``terok-l1-sidecar:ubuntu-24.04``).

    Raises:
        BuildError: If podman is missing or a build step fails.
    """
    _check_podman()

    base_image = _normalize_base_image(base_image)
    l0_tag = l0_image_tag(base_image)
    sidecar_tag = l1_sidecar_image_tag(base_image)

    if not rebuild and not full_rebuild and _image_exists(sidecar_tag) and _image_exists(l0_tag):
        return sidecar_tag

    # Ensure L0 exists (build if needed)
    if not _image_exists(l0_tag) or full_rebuild:
        build_base_images(base_image, rebuild=rebuild, full_rebuild=full_rebuild)

    import tempfile

    own_tmp = build_dir is None
    context = build_dir or Path(tempfile.mkdtemp(prefix="terok-agent-sidecar-"))

    try:
        prepare_build_context(context)
        cache_bust = str(int(time.time()))

        (context / "L1.sidecar.Dockerfile").write_text(
            render_l1_sidecar(l0_tag, tool_name=tool_name, cache_bust=cache_bust)
        )

        cmd = ["podman", "build", "-f", str(context / "L1.sidecar.Dockerfile")]
        cmd += ["--build-arg", f"BASE_IMAGE={l0_tag}"]
        cmd += ["--build-arg", f"TOOL_CACHE_BUST={cache_bust}"]
        cmd += ["-t", sidecar_tag]
        if full_rebuild:
            cmd.append("--no-cache")
        cmd.append(str(context))

        print("$", shlex.join(cmd))
        subprocess.run(cmd, check=True)
    except (OSError, subprocess.CalledProcessError) as e:
        raise BuildError(f"Sidecar image build failed: {e}") from e
    finally:
        if own_tmp:
            shutil.rmtree(context, ignore_errors=True)

    return sidecar_tag