Add a per-repo cache directive
Build directories can't ride in on `mounts`: a missing mount source is
skipped silently, which for a cache reads as nothing but slow builds.
Ramekin owns the host path and creates it instead.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index a2958bb..8cc96d4 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,30 @@ mounts {
Session mounts (the workspace, agent state, the rendered prompt, the outbox) are forced and cannot be overridden from config.
+### Caches
+
+A `cache` is a writable directory ramekin creates per repo and keeps across sessions. Build tools are the reason it exists: without one, every session pays a cold build, and pointing the tool at the workspace instead means the container and the host fight over one build directory.
+
+```kdl
+cache {
+ // Cargo's build directory, shadowing the host's target/ inside the
+ // workspace, so plain `cargo build` finds it
+ target
+ // uv's cache, at an explicit container path
+ uv "~/.cache/uv"
+}
+```
+
+Each child node names a directory under `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/`; its argument is the container path, resolved like a mount target (`~` is the container home, a relative path resolves against the workspace mount). Omit the argument and the name serves as the path — a bare `target` is the whole declaration for a build directory the tool already looks for in the workspace. Caches merge by name across layers, so a higher layer can retarget one, and two names claiming the same container path is an error.
+
+Unlike `mounts`, the host side isn't configurable and the directory is created rather than skipped when missing — a cache that silently failed to mount would look like nothing but slow builds. Caches are per repo because build directories can't be shared: tools take an exclusive lock on them for the duration of a build, and a toolchain change invalidates their contents wholesale. Download caches have the opposite shape — keyed by name and version, safe to share — so those belong in `mounts`:
+
+```kdl
+mounts {
+ "~/.cache/ramekin-rust/registry" target="/root/.cargo/registry" writable=#true
+}
+```
+
### Environment variables
`env` has exactly one syntax: a block with one child node per variable. The single argument is the value; omit it to pass the host's value through at run time (the value never lands in config or the generated compose file). `env` merges per variable across layers, overlaying the active profile's env.
diff --git a/skills/ramekin/SKILL.md b/skills/ramekin/SKILL.md
index 1662ea4..5273aa5 100644
--- a/skills/ramekin/SKILL.md
+++ b/skills/ramekin/SKILL.md
@@ -112,3 +112,16 @@ while `df` inside the container shows plenty free on the overlay.
Symptom: writes to the workspace fail mysteriously. Keep build
artifacts on the container side — e.g.
`CARGO_TARGET_DIR=/tmp/<something>` — which also speeds up builds.
+
+Better still, declare a `cache` in `.ramekin/config.kdl` and let ramekin
+keep the directory across sessions:
+
+```kdl
+cache {
+ target # shadows the workspace's own target/
+}
+```
+
+A bare node uses its name as the container path, and relative paths
+resolve inside the workspace, so the build tool needs no configuring and
+the host's build directory stays untouched.
diff --git a/src/config.rs b/src/config.rs
index 7df9ac4..c90972d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -164,6 +164,66 @@ impl ResolvedMount {
}
}
+/// A cache's name, checked to be a single path component.
+///
+/// The name is `join`ed onto this repo's `caches/` directory, so an unchecked
+/// one would let config write outside it. Parsing is the only way to build
+/// one, which keeps that guarantee structural rather than a convention the
+/// call site has to know about.
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
+pub struct CacheName(String);
+
+impl CacheName {
+ pub fn parse(name: &str) -> Result<Self> {
+ ensure!(!name.is_empty(), "a cache name can't be empty");
+ ensure!(
+ !name.contains('/'),
+ "cache `{name}`: a name is one directory under this repo's caches, \
+ so it can't contain `/`"
+ );
+ ensure!(
+ !name.starts_with('.'),
+ "cache `{name}`: a name can't start with `.`, which would hide the \
+ directory or escape the one above it"
+ );
+ Ok(Self(name.to_string()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Display for CacheName {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl AsRef<Path> for CacheName {
+ fn as_ref(&self) -> &Path {
+ Path::new(&self.0)
+ }
+}
+
+/// A per-repo cache: a writable directory ramekin creates and keeps across
+/// sessions, mounted at `target` in the container.
+///
+/// Unlike a `mounts` entry, the host side isn't configurable — it's
+/// `$XDG_DATA_HOME/ramekin/repos/<slug>/caches/<name>`, created eagerly like
+/// the sessions and outbox dirs. That keeps caches per repo (build
+/// directories can't be shared: tools lock them, and a differing toolchain
+/// invalidates their contents) without a host path in config, and without
+/// the silent skip that a missing mount source gets.
+#[derive(Debug, PartialEq, Clone)]
+pub struct Cache {
+ /// Directory name on the host, under this repo's `caches/`.
+ pub name: CacheName,
+ /// Container path, already resolved: `~` is the container home and a
+ /// relative path resolves against the workspace mount.
+ pub target: String,
+}
+
/// An environment variable for the container. `value: None` means the
/// variable passes through from the host environment at run time.
#[derive(Debug, PartialEq, Clone)]
@@ -181,6 +241,7 @@ pub struct ConfigLayer {
pub path: Option<PathBuf>,
pub mounts: Vec<ResolvedMount>,
pub env: Vec<EnvVar>,
+ pub caches: Vec<Cache>,
}
/// A value tagged with the config scope it came from.
@@ -342,6 +403,7 @@ impl ScopedConfig {
path: None,
mounts: binary_mounts(profile.agent),
env: Vec::new(),
+ caches: Vec::new(),
}];
layers.push(ConfigLayer {
scope: Scope::Profile,
@@ -352,6 +414,7 @@ impl ScopedConfig {
.filter_map(|m| m.resolve(workspace_target))
.collect(),
env: profile.env.clone(),
+ caches: Vec::new(),
});
layers.extend(builders.into_iter().map(LayerBuilder::build));
@@ -396,6 +459,41 @@ impl ScopedConfig {
.collect()
}
+ /// Merge caches from all layers, de-duplicated by name, so a higher layer
+ /// can retarget a cache a lower one declared. Two names pointing at the
+ /// same container path is a config error rather than a silent
+ /// last-writer-wins, since one of the two host directories would then
+ /// hold artifacts nothing ever reads.
+ pub fn merged_caches(&self) -> Result<Vec<ScopedValue<&Cache>>> {
+ let mut by_name: BTreeMap<&str, ScopedValue<&Cache>> = BTreeMap::new();
+ for layer in &self.layers {
+ for cache in &layer.caches {
+ by_name.insert(
+ cache.name.as_str(),
+ ScopedValue {
+ scope: layer.scope,
+ value: cache,
+ },
+ );
+ }
+ }
+
+ let mut by_target: BTreeMap<&str, &str> = BTreeMap::new();
+ for sv in by_name.values() {
+ if let Some(other) = by_target.insert(sv.value.target.as_str(), sv.value.name.as_str())
+ {
+ bail!(
+ "caches `{other}` and `{}` both mount at {}; give them separate paths \
+ or drop one",
+ sv.value.name,
+ sv.value.target,
+ );
+ }
+ }
+
+ Ok(by_name.into_values().collect())
+ }
+
/// Merge environment variables from all layers, de-duplicated by name.
///
/// Higher-precedence layers override variables with the same name.
@@ -433,6 +531,8 @@ struct LayerBuilder {
env_names: BTreeSet<String>,
profiles: Vec<Profile>,
profile_names: BTreeSet<String>,
+ caches: Vec<Cache>,
+ cache_names: BTreeSet<CacheName>,
selection: Option<String>,
}
@@ -447,6 +547,8 @@ impl LayerBuilder {
env_names: BTreeSet::new(),
profiles: Vec::new(),
profile_names: BTreeSet::new(),
+ caches: Vec::new(),
+ cache_names: BTreeSet::new(),
selection: None,
}
}
@@ -492,6 +594,19 @@ impl LayerBuilder {
);
self.profiles.push(profile);
}
+ for cache in raw.caches {
+ ensure!(
+ self.cache_names.insert(cache.name.clone()),
+ "{}: cache `{}` is defined twice in the {} layer",
+ file.display(),
+ cache.name,
+ self.scope,
+ );
+ self.caches.push(Cache {
+ target: resolve_container_target(&cache.target, workspace_target),
+ ..cache
+ });
+ }
for name in raw.selections {
ensure!(
self.selection.is_none(),
@@ -510,6 +625,7 @@ impl LayerBuilder {
path: self.path,
mounts: self.mounts,
env: self.env,
+ caches: self.caches,
}
}
}
@@ -523,6 +639,7 @@ impl LayerBuilder {
struct RawConfig {
mounts: Vec<Mount>,
env: Vec<EnvVar>,
+ caches: Vec<Cache>,
profiles: Vec<Profile>,
selections: Vec<String>,
}
@@ -541,6 +658,7 @@ fn parse_config(content: &str) -> Result<RawConfig> {
match node.name().value() {
"mounts" => raw.mounts.extend(parse_mounts(node)?),
"env" => raw.env.extend(parse_env(node)?),
+ "cache" => raw.caches.extend(parse_cache(node)?),
// `profile "name" { ... }` defines; `profile "name"` selects.
"profile" => match parse_profile(node)? {
ProfileNode::Definition(profile) => raw.profiles.push(profile),
@@ -729,6 +847,33 @@ fn parse_env(node: &KdlNode) -> Result<Vec<EnvVar>> {
.collect()
}
+/// Parse a `cache` block: one child node per cache, named by the host
+/// directory it gets under this repo's `caches/`, with the container path as
+/// its argument. A bare node takes the name as its path too, which for the
+/// common case (a build directory the tool looks for in the workspace) is
+/// the whole declaration: `cache { target }`.
+fn parse_cache(node: &KdlNode) -> Result<Vec<Cache>> {
+ ensure!(
+ node.entries().is_empty(),
+ "`cache` takes a block (cache {{ target }}), not inline values"
+ );
+
+ let Some(children) = node.children() else {
+ return Ok(Vec::new());
+ };
+ children
+ .nodes()
+ .iter()
+ .map(|child| {
+ let name = CacheName::parse(child.name().value())?;
+ Ok(Cache {
+ target: optional_string_arg(child)?.unwrap_or_else(|| name.to_string()),
+ name,
+ })
+ })
+ .collect()
+}
+
/// The node's single string argument, required.
fn single_string_arg(node: &KdlNode) -> Result<String> {
optional_string_arg(node)?
@@ -1058,6 +1203,118 @@ mod tests {
assert_eq!(resolved.target, "/tmp");
}
+ #[test]
+ fn parse_cache_block() {
+ let raw = parse_config(
+ r#"
+ cache {
+ target "/root/target"
+ uv "~/.cache/uv"
+ }
+ "#,
+ )
+ .unwrap();
+ assert_eq!(
+ raw.caches,
+ vec![
+ Cache {
+ name: cache_name("target"),
+ target: "/root/target".into(),
+ },
+ Cache {
+ name: cache_name("uv"),
+ target: "~/.cache/uv".into(),
+ },
+ ]
+ );
+ }
+
+ /// Cache targets resolve like mount targets, so a relative one lands in
+ /// the workspace — shadowing the host's build dir with the container's.
+ #[test]
+ fn cache_target_resolves_against_the_workspace() {
+ let raw = parse_config(r#"cache { target "target" }"#).unwrap();
+ let mut builder = LayerBuilder::new(Scope::Project, None);
+ builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+ let layer = builder.build();
+ assert_eq!(layer.caches[0].target, "/workspace/test-slug/target");
+ }
+
+ #[test]
+ fn cache_name_cannot_escape_its_directory() {
+ let err = parse_config(r#"cache { "../elsewhere" "/root/target" }"#).unwrap_err();
+ assert!(err.to_string().contains("can't contain `/`"), "{err}");
+
+ let err = parse_config(r#"cache { ".." "/root/target" }"#).unwrap_err();
+ assert!(err.to_string().contains("can't start with `.`"), "{err}");
+ }
+
+ #[test]
+ fn cache_name_cannot_be_empty() {
+ let err = CacheName::parse("").unwrap_err();
+ assert!(err.to_string().contains("can't be empty"), "{err}");
+ }
+
+ /// A bare node names a directory in the workspace, so `cache { target }`
+ /// covers a build dir the tool already looks for there.
+ #[test]
+ fn bare_cache_takes_its_name_as_the_path() {
+ let raw = parse_config(r#"cache { target }"#).unwrap();
+ assert_eq!(
+ raw.caches,
+ vec![Cache {
+ name: cache_name("target"),
+ target: "target".into(),
+ }]
+ );
+
+ let mut builder = LayerBuilder::new(Scope::Project, None);
+ builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+ assert_eq!(
+ builder.build().caches[0].target,
+ "/workspace/test-slug/target"
+ );
+ }
+
+ #[test]
+ fn merged_caches_deduplicates_by_name() {
+ let cache = |name: &str, target: &str| Cache {
+ name: cache_name(name),
+ target: target.into(),
+ };
+ let user = ConfigLayer {
+ caches: vec![cache("target", "/root/target"), cache("uv", "/root/uv")],
+ ..layer(Scope::User, vec![])
+ };
+ let project = ConfigLayer {
+ caches: vec![cache("target", "/root/elsewhere")],
+ ..layer(Scope::Project, vec![])
+ };
+ let config = scoped(vec![user, project]);
+ let merged = config.merged_caches().unwrap();
+ assert_eq!(merged.len(), 2);
+ let target = merged
+ .iter()
+ .find(|sv| sv.value.name.as_str() == "target")
+ .unwrap();
+ assert_eq!(target.value.target, "/root/elsewhere");
+ assert_eq!(target.scope, Scope::Project);
+ }
+
+ #[test]
+ fn two_caches_at_one_target_is_an_error() {
+ let cache = |name: &str| Cache {
+ name: cache_name(name),
+ target: "/root/target".into(),
+ };
+ let user = ConfigLayer {
+ caches: vec![cache("a"), cache("b")],
+ ..layer(Scope::User, vec![])
+ };
+ let err = scoped(vec![user]).merged_caches().unwrap_err().to_string();
+ assert!(err.contains("both mount at /root/target"), "{err}");
+ }
+
#[test]
fn scope_display() {
assert_eq!(Scope::Binary.to_string(), "binary");
@@ -1071,9 +1328,14 @@ mod tests {
path: None,
mounts,
env: Vec::new(),
+ caches: Vec::new(),
}
}
+ fn cache_name(name: &str) -> CacheName {
+ CacheName::parse(name).unwrap()
+ }
+
fn mount(source: &str, target: &str, writable: bool) -> ResolvedMount {
ResolvedMount {
source: PathBuf::from(source),
@@ -1262,6 +1524,7 @@ mod tests {
value: Some("user".into()),
},
],
+ caches: Vec::new(),
};
let project = ConfigLayer {
scope: Scope::Project,
@@ -1271,6 +1534,7 @@ mod tests {
name: "FOO".into(),
value: Some("project".into()),
}],
+ caches: Vec::new(),
};
let config = scoped(vec![user, project]);
let merged = config.merged_env();
diff --git a/src/main.rs b/src/main.rs
index b80c258..b0c2004 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -527,6 +527,29 @@ impl Ramekin {
})
}
+ /// Host directory backing this repo's caches, one subdirectory per
+ /// configured `cache`.
+ fn repo_caches_dir(&self) -> PathBuf {
+ self.data_home
+ .join(format!("repos/{}/caches", self.repo_slug))
+ }
+
+ /// Mounts for the configured caches. Forced like the session mounts: the
+ /// container path comes from config, the host path never does.
+ fn cache_mounts(&self) -> Result<Vec<config::ResolvedMount>> {
+ let base = self.repo_caches_dir();
+ Ok(self
+ .config
+ .merged_caches()?
+ .into_iter()
+ .map(|sv| config::ResolvedMount {
+ source: base.join(&sv.value.name),
+ target: sv.value.target.clone(),
+ writable: true,
+ })
+ .collect())
+ }
+
/// Session plumbing mounts shared by both agents: the rendered prompt,
/// the outbox, and the workspace.
fn session_mounts(&self, session_dir: &Path, outbox_dir: &Path) -> Vec<config::ResolvedMount> {
@@ -549,11 +572,12 @@ impl Ramekin {
mounts
}
- /// Merge config mounts with the forced session mounts, ordered
- /// lexicographically by target so parents precede children.
+ /// Merge config mounts with the forced ones (session plumbing and
+ /// caches), ordered lexicographically by target so parents precede
+ /// children.
fn final_mounts<'a>(
&'a self,
- session_mounts: &'a [config::ResolvedMount],
+ forced: &'a [config::ResolvedMount],
) -> Vec<&'a config::ResolvedMount> {
let mut by_target: BTreeMap<&str, &config::ResolvedMount> = self
.config
@@ -561,7 +585,7 @@ impl Ramekin {
.into_iter()
.map(|sv| (sv.value.target.as_str(), sv.value))
.collect();
- for mount in session_mounts {
+ for mount in forced {
by_target.insert(mount.target.as_str(), mount);
}
by_target.into_values().collect()
@@ -646,6 +670,25 @@ impl Ramekin {
);
}
+ // Caches
+ let caches = self.config.merged_caches()?;
+ if !caches.is_empty() {
+ println!();
+ println!("Caches");
+ let base = self.repo_caches_dir();
+ let scopes: BTreeSet<_> = caches.iter().map(|sv| sv.scope).collect();
+ for scope in scopes {
+ println!(" {}", scope_label(scope));
+ for sv in caches.iter().filter(|sv| sv.scope == scope) {
+ println!(
+ " {} → {}",
+ base.join(&sv.value.name).display(),
+ sv.value.target
+ );
+ }
+ }
+ }
+
// Environment
if !merged_env.is_empty() {
println!();
@@ -766,8 +809,14 @@ impl Ramekin {
outbox::create_session(&self.data_home, &self.repo_slug, &session_id, agent)
.wrap_err("failed to create session outbox")?;
- let session_mounts = self.session_mounts(&session_dir, &outbox_dir);
- let all_mounts = self.final_mounts(&session_mounts);
+ // Caches are created rather than skipped when absent: a cache that
+ // silently didn't mount would look like nothing but slow builds.
+ let mut forced_mounts = self.session_mounts(&session_dir, &outbox_dir);
+ for mount in self.cache_mounts()? {
+ fs_err::create_dir_all(&mount.source).into_diagnostic()?;
+ forced_mounts.push(mount);
+ }
+ let all_mounts = self.final_mounts(&forced_mounts);
let env_vars = self.config.merged_env();
let compose = generate_compose(ComposeParams {
dockerfile: &dockerfile,