Collapse to one Dockerfile carrying both agents
Per-agent Dockerfiles existed to keep two image definitions from
fighting over one tag; a single Dockerfile installing both pi and
Claude Code dissolves the problem instead. The image has no
ENTRYPOINT — the generated compose config sets it to the active
agent per session — so one `ramekin-agent` tag serves everything,
project Dockerfiles go back to plain `FROM ramekin-agent` (no ARG
BASE), and one project image serves both agents. Claude's managed
settings and IS_SANDBOX ride along in the shared image; pi ignores
them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayyqfpg98CeZPmtMPXQEFo
diff --git a/.ramekin/Dockerfile b/.ramekin/Dockerfile
index 2cc7e82..2e13613 100644
--- a/.ramekin/Dockerfile
+++ b/.ramekin/Dockerfile
@@ -1,5 +1,4 @@
-ARG BASE
-FROM ${BASE}
+FROM ramekin-agent
ENV RANGER_DEFAULT_BACKLOG="ramekin"
diff --git a/AGENTS.md b/AGENTS.md
index 08e0f1b..f62128e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Project
-Ramekin is a containerized harness for running coding agents — the [pi coding agent](https://github.com/badlogic/pi-mono) or Claude Code, selected via profiles. A Rust CLI builds per-agent Docker images, generates a compose config at runtime, and attaches the user's terminal to the agent container. Network restriction via a firewall sidecar is planned but not yet implemented.
+Ramekin is a containerized harness for running coding agents — the [pi coding agent](https://github.com/badlogic/pi-mono) or Claude Code, selected via profiles. A Rust CLI builds a Docker image carrying both agents, generates a compose config at runtime, and attaches the user's terminal to the agent container. Network restriction via a firewall sidecar is planned but not yet implemented.
## Repository layout
@@ -14,8 +14,7 @@ src/
outbox.rs # Pending config proposals: scan, map to host sources, apply/discard
build.rs # Sets RAMEKIN_VERSION from env or git rev
assets/
- Dockerfile # Pi base image (Node.js + pi + jj)
- Dockerfile.claude # Claude Code base image (+ managed settings, IS_SANDBOX)
+ Dockerfile # Base image: both agents (+ claude managed settings, IS_SANDBOX)
ramekin-prompt.md # System prompt template appended inside the container
clippy.toml # Disallows std::fs in favor of fs-err
justfile # Local dev tasks (check, fmt, clippy, test, install)
@@ -51,7 +50,7 @@ just # All four
- Persistence is per-agent, opposite policies: pi is ephemeral-by-default (fresh session dir at `/root/.pi/agent`; allowlisted `auth.json` from `$XDG_DATA_HOME/ramekin/agents/pi/` and per-repo `sessions/` bound on top; teardown logs discarded writes). Claude is persist-by-default (`~/.claude` + `~/.claude.json` from `$XDG_DATA_HOME/ramekin/agents/`, shared across repos; session-scoped dirs bound over the `CLAUDE_EPHEMERAL` denylist).
- Each workspace mounts at `/workspace/<slug>` (slug is `<dirname>-<hash>`, never a shared `/workspace`) so cwd-keyed agent state stays distinct per repo; compose's `working_dir` puts the agent there on start.
- Docker compose config is generated at runtime via `serde_yaml` over a typed `ComposeConfig` struct, not a static file. Volume mounts use the long-form bind syntax (`{type: bind, source, target, read_only}`), ordered lexicographically by target so parents precede children. Passthrough env vars render as bare names in the environment list.
-- Base images build to per-agent tags (`ramekin-pi`, `ramekin-claude`). A project `.ramekin/Dockerfile` declares `ARG BASE` / `FROM ${BASE}`; ramekin passes the active agent's tag, and project image tags are repo- and agent-specific. Image builds forward a host GitHub token (env vars or `gh auth token`) as a BuildKit secret for API calls.
+- One base image (`ramekin-agent`) carries both agents; it has no ENTRYPOINT, and the generated compose config sets `entrypoint` to `pi` or `claude` per session. A project `.ramekin/Dockerfile` builds `FROM ramekin-agent` with a repo-specific tag. Image builds forward a host GitHub token (env vars or `gh auth token`) as a BuildKit secret for API calls.
- The outbox (`src/outbox.rs`) is the only write path for shared config: each session mounts a fresh dir at `/root/.ramekin/outbox`; proposals map back to host sources via the agent allowlist plus an `.agent` sidecar written outside the mount; `ramekin outbox list|diff|apply|discard` reviews them.
- `Ramekin::resolve` is side-effect free (so `ramekin config` never mutates state); materialization happens in `run` via `AgentState::prepare`/`prepare_session`.
- The `ramekin-prompt.md` template is rendered per session (`{{WORKSPACE_PATH}}` → the workspace target), mounted read-only at `/root/.ramekin/ramekin-prompt.md`, and passed via `--append-system-prompt` (pi) / `--append-system-prompt-file` (Claude — its plain flag takes a literal string).
diff --git a/README.md b/README.md
index 57661aa..d152dd7 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Ramekin builds a Docker image with the agent and its dependencies, starts it via
A Rust CLI orchestrates a Docker Compose stack. On each run it:
1. Resolves the active profile (which picks the agent) and merges config layers
-2. Builds the agent's base image (`ramekin-pi` or `ramekin-claude`), and a project-specific layer if one exists
+2. Builds the base image (`ramekin-agent`, carrying both agents), and a project-specific layer if one exists
3. Generates a compose config, renders the system prompt, and creates fresh agent dirs, all in a session-scoped cache directory
4. Starts the agent container with the workspace mounted at `/workspace/<slug>` (where `<slug>` is `<dirname>-<hash>`, so cwd-keyed agent state never collides across repos)
5. Attaches interactively, then tears down on exit — logging any state the agent wrote to its session-scoped dirs before discarding it, and keeping any config proposals the agent left in its outbox
@@ -179,13 +179,12 @@ For Claude, the base image bakes in yolo mode: managed settings set `permissions
### Custom Dockerfile
-Place a `Dockerfile` at `.ramekin/Dockerfile` in your workspace to extend the base agent image. Declare `ARG BASE` / `FROM ${BASE}` — ramekin passes the active agent's base tag, so one project Dockerfile serves both agents. The base images include Node.js, the agent, git, jj, ripgrep, fd, just, jq, difftastic, dotslash, and ranger.
+Place a `Dockerfile` at `.ramekin/Dockerfile` in your workspace to extend the base agent image. Use `FROM ramekin-agent` to layer on top — the base image carries both agents (the compose config picks the entrypoint per session) plus Node.js, git, jj, ripgrep, fd, just, jq, difftastic, dotslash, and ranger.
The workspace is used as the build context, so `COPY` instructions work relative to the project root.
```dockerfile
-ARG BASE
-FROM ${BASE}
+FROM ramekin-agent
RUN apt-get update && apt-get install -y ruby && rm -rf /var/lib/apt/lists/*
```
diff --git a/assets/Dockerfile b/assets/Dockerfile
index cce9b4f..54c44fa 100644
--- a/assets/Dockerfile
+++ b/assets/Dockerfile
@@ -33,8 +33,27 @@ RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
| tar xz --strip-components=0 -C /usr/local/bin ./jj
RUN --mount=type=cache,target=/root/.npm \
- npm install -g @mariozechner/pi-coding-agent
+ npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
+
+# Claude Code refuses --dangerously-skip-permissions when running as root
+# unless IS_SANDBOX=1 acknowledges the sandbox. The container is the sandbox.
+# Pi ignores this.
+ENV IS_SANDBOX=1
+
+# Run Claude in bypass-permissions mode and skip the bypass dialog. Managed
+# settings is the highest tier, so this overrides whatever's in the user's
+# mounted ~/.claude. defaultMode replaces the --dangerously-skip-permissions
+# CLI flag; skipDangerousModePermissionPrompt suppresses the once-per-machine
+# acceptance dialog. The container is already a sandbox (see IS_SANDBOX), so
+# prompting is redundant.
+RUN mkdir -p /etc/claude-code && cat > /etc/claude-code/managed-settings.json <<'EOF'
+{
+ "permissions": { "defaultMode": "bypassPermissions" },
+ "skipDangerousModePermissionPrompt": true
+}
+EOF
WORKDIR /workspace
-ENTRYPOINT ["pi"]
+# No ENTRYPOINT: one image carries both agents, and the generated compose
+# config picks `pi` or `claude` per session.
diff --git a/assets/Dockerfile.claude b/assets/Dockerfile.claude
deleted file mode 100644
index f1303b0..0000000
--- a/assets/Dockerfile.claude
+++ /dev/null
@@ -1,57 +0,0 @@
-FROM node:24-trixie-slim
-
-RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
- --mount=type=cache,target=/var/lib/apt,sharing=locked \
- rm -f /etc/apt/apt.conf.d/docker-clean \
- && apt-get update && apt-get install -y --no-install-recommends \
- build-essential \
- ca-certificates \
- curl \
- fd-find \
- git \
- jq \
- just \
- ripgrep \
- && ln -sf /usr/bin/fdfind /usr/bin/fd
-
-RUN --mount=type=cache,target=/tmp/downloads \
- curl -fsSL "https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz" \
- | tar xz -C /usr/local/bin difft \
- && curl -fsSL "https://github.com/facebook/dotslash/releases/latest/download/dotslash-linux-musl.aarch64.tar.gz" \
- | tar xz -C /usr/local/bin dotslash \
- && curl -fsSL "https://github.com/kejadlen/ranger/releases/latest/download/ranger-aarch64-unknown-linux-gnu.tar.gz" \
- | tar xz -C /usr/local/bin ranger
-
-RUN --mount=type=secret,id=github-token,target=/run/secrets/github-token \
- if [ -s /run/secrets/github-token ]; then \
- JJ_VERSION=$(curl -s -H "Authorization: Bearer $(cat /run/secrets/github-token)" \
- https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
- else \
- JJ_VERSION=$(curl -s https://api.github.com/repos/jj-vcs/jj/releases/latest | jq -r .tag_name); \
- fi && \
- curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" \
- | tar xz --strip-components=0 -C /usr/local/bin ./jj
-
-RUN --mount=type=cache,target=/root/.npm \
- npm install -g @anthropic-ai/claude-code
-
-# Claude Code refuses --dangerously-skip-permissions when running as root
-# unless IS_SANDBOX=1 acknowledges the sandbox. The container is the sandbox.
-ENV IS_SANDBOX=1
-
-# Run Claude in bypass-permissions mode and skip the bypass dialog. Managed
-# settings is the highest tier, so this overrides whatever's in the user's
-# mounted ~/.claude. defaultMode replaces the --dangerously-skip-permissions
-# CLI flag; skipDangerousModePermissionPrompt suppresses the once-per-machine
-# acceptance dialog. The container is already a sandbox (see IS_SANDBOX), so
-# prompting is redundant.
-RUN mkdir -p /etc/claude-code && cat > /etc/claude-code/managed-settings.json <<'EOF'
-{
- "permissions": { "defaultMode": "bypassPermissions" },
- "skipDangerousModePermissionPrompt": true
-}
-EOF
-
-WORKDIR /workspace
-
-ENTRYPOINT ["claude"]
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index ae86915..4fa265e 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -55,8 +55,10 @@ state while auth stays global; yolo mode belongs in the image (managed
settings + `IS_SANDBOX=1`); keep side-effect-free `ramekin config`,
deterministic parent-before-child mount ordering, long-form compose binds,
and the GitHub-token BuildKit secret. Anti-lesson: one shared image tag
-across agents silently redefines `FROM ramekin-agent` — images must be
-per-agent.
+over two *different* Dockerfiles silently redefines `FROM ramekin-agent`.
+Resolved by collapsing to a single Dockerfile carrying both agents — one
+tag, one definition, entrypoint chosen per session — rather than per-agent
+images.
## Design
@@ -247,13 +249,12 @@ rendered prompt. A possible simplification falls out: if pi groups sessions
by cwd on its own, distinct workspace paths may make ramekin's per-repo
`sessions/` mount redundant — verify against pi's actual layout.
-Base images build to
-per-agent tags (`ramekin-pi`, `ramekin-claude`); a project
-`.ramekin/Dockerfile` declares `ARG BASE` / `FROM ${BASE}` and ramekin
-passes the active agent's tag, so one project Dockerfile serves both.
-Project image tags stay repo-specific and gain the agent suffix. Concurrent
-builds of the same tag are idempotent; per-agent tags remove the pi/claude
-race.
+One base image (`ramekin-agent`) carries both agents and no ENTRYPOINT;
+the generated compose config sets the entrypoint to `pi` or `claude` per
+session. A project `.ramekin/Dockerfile` builds `FROM ramekin-agent` with
+a repo-specific tag, so one project image serves both agents too.
+Concurrent builds of the same tag are idempotent, and a single Dockerfile
+means there is no pi/claude tag race to avoid.
### Outbox
@@ -290,9 +291,9 @@ secret, side-effect-free `config`) rather than rebasing the branch:
agent-config mounts replace the `pi {}` block, staples move into the
binary, teardown report on discarded session-dir writes. Multi-session
works from here on.
-2. Claude support: agent plumbing, `Dockerfile.claude` (harvested),
- per-agent tags + `ARG BASE` project builds, ephemeral denylist mounts
- over `~/.claude` junk.
+2. Claude support: agent plumbing, claude install + managed settings in
+ the shared Dockerfile (harvested), per-session entrypoint selection,
+ ephemeral denylist mounts over `~/.claude` junk.
3. Profiles: KDL `profile` blocks + builtin trivial profiles, user-KDL
machine default, project `profile` scalar, `-p`, env passthrough,
user layer reads `*.kdl`.
diff --git a/src/main.rs b/src/main.rs
index ef32e2b..0fceaac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,12 +13,16 @@ use serde::Serialize;
use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
-const PI_DOCKERFILE: &str = include_str!("../assets/Dockerfile");
-const CLAUDE_DOCKERFILE: &str = include_str!("../assets/Dockerfile.claude");
+const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
const RAMEKIN_PROMPT: &str = include_str!("../assets/ramekin-prompt.md");
const VERSION: &str = env!("RAMEKIN_VERSION");
+/// Tag of the base image. One image carries both agents — the generated
+/// compose config picks the entrypoint per session — so concurrent sessions
+/// of different agents build the same idempotent tag.
+const BASE_IMAGE: &str = "ramekin-agent";
+
/// Container path of the rendered per-session system prompt.
const PROMPT_TARGET: &str = "/root/.ramekin/ramekin-prompt.md";
@@ -542,11 +546,6 @@ impl Ramekin {
by_target.into_values().collect()
}
- /// Tag of the active agent's base image.
- fn base_image(&self) -> String {
- format!("ramekin-{}", self.config.agent())
- }
-
fn config(&self) -> Result<()> {
println!("Workspace");
println!(" {} → {}", self.workspace.display(), self.workspace_target);
@@ -642,9 +641,9 @@ impl Ramekin {
println!();
println!("Dockerfile");
match &self.custom_dockerfile {
- Some(path) => println!(" ✓ {} (BASE={})", path.display(), self.base_image()),
+ Some(path) => println!(" ✓ {} (FROM {BASE_IMAGE})", path.display()),
None => {
- println!(" embedded ({})", self.base_image());
+ println!(" embedded ({BASE_IMAGE})");
println!(
" ✗ {} (not found)",
self.workspace.join(".ramekin/Dockerfile").display()
@@ -668,14 +667,9 @@ impl Ramekin {
fs_err::create_dir_all(&self.cache_dir).into_diagnostic()?;
self.agent_state.prepare(&self.xdg)?;
- // Write the embedded Dockerfile to the cache directory, one file per
- // agent so concurrent sessions of different agents don't race.
- let dockerfile_source = match agent {
- config::Agent::Pi => PI_DOCKERFILE,
- config::Agent::Claude => CLAUDE_DOCKERFILE,
- };
- let base_dockerfile = self.cache_dir.join(format!("Dockerfile.{agent}"));
- fs_err::write(&base_dockerfile, dockerfile_source).into_diagnostic()?;
+ // Write the embedded Dockerfile to the cache directory
+ let base_dockerfile = self.cache_dir.join("Dockerfile");
+ fs_err::write(&base_dockerfile, DOCKERFILE).into_diagnostic()?;
// The base image fetches release metadata from the GitHub API at
// build time. Pass a host token so the build doesn't get rate-limited.
@@ -684,7 +678,6 @@ impl Ramekin {
info!("authenticated GitHub API for image build");
}
- let base_image = self.base_image();
if rebuild {
info!("rebuilding base image (no cache)");
} else {
@@ -692,7 +685,7 @@ impl Ramekin {
}
let mut build_cmd = Command::new("docker");
build_cmd
- .args(["build", "-t", &base_image, "-f"])
+ .args(["build", "-t", BASE_IMAGE, "-f"])
.arg(&base_dockerfile);
if let Some(token) = &gh_token {
build_cmd
@@ -712,24 +705,22 @@ impl Ramekin {
}
// Determine the final dockerfile, build context, and image tag. A
- // custom Dockerfile declares `ARG BASE` / `FROM ${BASE}` and gets the
- // active agent's base tag passed in, plus a repo- and agent-specific
- // image tag so the two never collide.
- let (dockerfile, build_context, image, build_args) = match &self.custom_dockerfile {
+ // custom Dockerfile gets a repo-specific tag so it doesn't collide
+ // with the base image it builds `FROM`; sharing the tag would make
+ // `docker compose up` reuse the base instead of the project layer.
+ let (dockerfile, build_context, image) = match &self.custom_dockerfile {
Some(custom) => {
info!("building project image from .ramekin/Dockerfile");
(
custom.clone(),
self.workspace.clone(),
- project_image_name(&self.repo_slug, agent),
- BTreeMap::from([("BASE", base_image.clone())]),
+ project_image_name(&self.repo_slug),
)
}
None => (
base_dockerfile,
self.cache_dir.clone(),
- base_image.clone(),
- BTreeMap::new(),
+ BASE_IMAGE.to_string(),
),
};
@@ -757,11 +748,12 @@ impl Ramekin {
let compose = generate_compose(ComposeParams {
dockerfile: &dockerfile,
build_context: &build_context,
- build_args,
mounts: &all_mounts,
env_vars: &env_vars,
image: &image,
working_dir: &self.workspace_target,
+ // The image has no ENTRYPOINT; the compose config picks the agent.
+ entrypoint: agent.name(),
prompt_flag: match agent {
// Pi's --append-system-prompt accepts a file path; Claude's
// takes a literal string, so it needs the -file variant to
@@ -967,11 +959,12 @@ fn repo_slug(workspace: &Path) -> String {
}
/// Docker image tag for a workspace's project image, built from its
-/// `.ramekin/Dockerfile`. Repo- and agent-specific so it neither collides
-/// with the per-agent base tags nor lets one agent's project layer shadow
-/// the other's. Lowercased because Docker repository names must be lowercase.
-fn project_image_name(repo_slug: &str, agent: config::Agent) -> String {
- format!("ramekin-{repo_slug}-{agent}").to_lowercase()
+/// `.ramekin/Dockerfile`. Kept distinct from the base tag so `docker compose
+/// up` builds the project layer instead of reusing the base image that
+/// shares the tag. Lowercased because Docker repository names must be
+/// lowercase.
+fn project_image_name(repo_slug: &str) -> String {
+ format!("ramekin-{repo_slug}").to_lowercase()
}
#[derive(Serialize)]
@@ -991,6 +984,7 @@ struct AgentService {
stdin_open: bool,
tty: bool,
working_dir: String,
+ entrypoint: Vec<String>,
environment: Vec<String>,
volumes: Vec<VolumeBind>,
command: Vec<String>,
@@ -1000,8 +994,6 @@ struct AgentService {
struct BuildConfig {
context: String,
dockerfile: String,
- #[serde(skip_serializing_if = "BTreeMap::is_empty")]
- args: BTreeMap<&'static str, String>,
}
/// Long-form compose bind mount. Avoids the `source:target[:ro]` short form,
@@ -1021,11 +1013,13 @@ struct VolumeBind {
struct ComposeParams<'a> {
dockerfile: &'a Path,
build_context: &'a Path,
- build_args: BTreeMap<&'static str, String>,
mounts: &'a [&'a config::ResolvedMount],
env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
image: &'a str,
working_dir: &'a str,
+ /// The agent binary to run — the image carries both, with no ENTRYPOINT
+ /// of its own.
+ entrypoint: &'a str,
prompt_flag: &'a str,
agent_args: &'a [String],
}
@@ -1035,11 +1029,11 @@ fn generate_compose(params: ComposeParams) -> String {
let ComposeParams {
dockerfile,
build_context,
- build_args,
mounts,
env_vars,
image,
working_dir,
+ entrypoint,
prompt_flag,
agent_args,
} = params;
@@ -1078,12 +1072,12 @@ fn generate_compose(params: ComposeParams) -> String {
build: BuildConfig {
context: build_context.display().to_string(),
dockerfile: dockerfile.display().to_string(),
- args: build_args,
},
image: image.to_string(),
stdin_open: true,
tty: true,
working_dir: working_dir.to_string(),
+ entrypoint: vec![entrypoint.to_string()],
environment,
volumes,
command,
@@ -1099,18 +1093,13 @@ mod tests {
use super::*;
#[test]
- fn project_image_name_is_repo_and_agent_specific() {
- let pi = project_image_name("lit-rs-deadbeef", config::Agent::Pi);
- let claude = project_image_name("lit-rs-deadbeef", config::Agent::Claude);
- // Must not collide with the per-agent base tags, or `docker compose
- // up` reuses the base instead of building the project Dockerfile.
- assert_ne!(pi, "ramekin-pi");
- assert_ne!(claude, "ramekin-claude");
- // One project Dockerfile serves both agents; the tags must differ or
- // one agent's project layer shadows the other's.
- assert_ne!(pi, claude);
+ fn project_image_name_is_repo_specific_and_distinct_from_base() {
+ let name = project_image_name("lit-rs-deadbeef");
+ // Must not collide with the base image tag, or `docker compose up`
+ // reuses the base instead of building the project Dockerfile.
+ assert_ne!(name, BASE_IMAGE);
// Docker repository names must be lowercase.
- assert_eq!(pi, pi.to_lowercase(), "got: {pi}");
+ assert_eq!(name, name.to_lowercase(), "got: {name}");
}
fn compose_params<'a>(
@@ -1118,13 +1107,13 @@ mod tests {
env_vars: &'a [config::ScopedValue<&'a config::EnvVar>],
) -> ComposeParams<'a> {
ComposeParams {
- dockerfile: Path::new("/cache/Dockerfile.pi"),
+ dockerfile: Path::new("/cache/Dockerfile"),
build_context: Path::new("/cache"),
- build_args: BTreeMap::new(),
mounts,
env_vars,
- image: "ramekin-pi",
+ image: BASE_IMAGE,
working_dir: "/workspace/x-1",
+ entrypoint: "pi",
prompt_flag: "--append-system-prompt",
agent_args: &[],
}
@@ -1143,8 +1132,6 @@ mod tests {
assert!(yaml.contains("target: /root/.config/git"), "{yaml}");
assert!(yaml.contains("read_only: true"), "{yaml}");
assert!(yaml.contains("working_dir: /workspace/x-1"), "{yaml}");
- // No build args → no args key at all.
- assert!(!yaml.contains("args:"), "{yaml}");
}
#[test]
@@ -1174,12 +1161,13 @@ mod tests {
}
#[test]
- fn generate_compose_carries_base_build_arg() {
+ fn generate_compose_sets_the_agent_entrypoint() {
let mut params = compose_params(&[], &[]);
- params.build_args = BTreeMap::from([("BASE", "ramekin-claude".to_string())]);
+ params.entrypoint = "claude";
params.prompt_flag = "--append-system-prompt-file";
let yaml = generate_compose(params);
- assert!(yaml.contains("BASE: ramekin-claude"), "{yaml}");
+ // One image carries both agents; the compose entrypoint picks one.
+ assert!(yaml.contains("entrypoint:\n - claude"), "{yaml}");
// Claude needs the -file variant: the plain flag would append the
// literal path string instead of the prompt contents.
assert!(yaml.contains("--append-system-prompt-file"), "{yaml}");