config: replace per-mount blocks with one mounts block
Every mount needed its own `mounts { source "..." }` block, repeating
the keyword for each entry. A block now holds one child node per mount
— the node name is the host source path, with `target` and `writable`
properties — mirroring the env block one-child-per-item shape. The
retired field names error with a pointer at the new form, so old
configs fail loudly instead of misparsing `source` as a source path.
Assisted-by: Claude Fable 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index da43c64..d4ad677 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@ just # All four
## Architecture notes
- The config redesign is documented in `docs/config-redesign.md`; all four steps of its sequencing are implemented.
-- Config files are parsed directly with the `kdl` crate (`parse_config` in `config.rs`), not serde: the grammar is `mounts` blocks, one `env` block syntax (bare child = host passthrough), and `profile` nodes (with children = definition, bare = selection). Unknown nodes fail loudly.
+- Config files are parsed directly with the `kdl` crate (`parse_config` in `config.rs`), not serde: the grammar is `mounts` blocks (one child node per mount: name = host source, `target`/`writable` properties), one `env` block syntax (bare child = host passthrough), and `profile` nodes (with children = definition, bare = selection). Unknown nodes fail loudly.
- Config merges layers, lowest precedence first: binary (staples `~/.config/git`/`~/.config/jj` plus the active agent's config allowlist, mounted read-only, canonicalized, skip-if-missing), profile (the active profile's env/mounts), user (every `*.kdl` in `~/.config/ramekin/`, merged as one layer, duplicate keys within the layer are errors), and project (`.ramekin/config.kdl`). A `/dev/null` source masks (removes) a mount inherited from a lower layer. `env` merges per variable; profiles merge by name, last writer takes the whole definition.
- Profiles subsume agent selection: the binary ships trivial `pi`/`claude` profiles, selection precedence is binary < user < project < project-local < `-p`. `Agent` (in `config.rs`) carries each agent's host config dir, allowlist, and container config dir; `AgentState` (in `main.rs`) carries its persistent host paths and session mounts.
- 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).
diff --git a/README.md b/README.md
index 5edffb8..f6a23b7 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ profile "claude-bedrock" {
CLAUDE_CODE_USE_BEDROCK "1"
AWS_PROFILE // bare = pass the host's value through
}
- mounts { source "~/.aws" }
+ mounts { "~/.aws" }
}
profile "pi-glm" {
@@ -107,40 +107,29 @@ Mount configuration merges across layers, lowest to highest precedence:
Additional host paths can be mounted into the container via the KDL layers. Directories, files, and devices (such as `/dev/null`) all work. Mounts whose source doesn't exist on the host are silently skipped.
```kdl
-// Mount ranger database (writable)
mounts {
- source "~/.local/share/ranger"
- writable
-}
-
-// Mount extra data at an explicit path
-mounts {
- source "~/datasets"
- target "/root/datasets"
+ // Ranger database, writable
+ "~/.local/share/ranger" writable=#true
+ // Extra data at an explicit container path
+ "~/datasets" target="/root/datasets"
}
```
-Each `mounts` block supports:
+A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory), with optional properties:
-| Field | Required | Description |
-|---|---|---|
-| `source` | yes | Host path (`~` expands to home directory) |
-| `target` | no | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
-| `writable` | no | Allow writes (read-only by default) |
+| Property | Description |
+|---|---|
+| `target` | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
+| `writable` | `#true` allows writes (read-only by default) |
When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source *masks*: it removes a mount inherited from a lower layer (say, a staple or an agent-config entry this machine or project doesn't want), and where there's nothing to remove it stays a real bind, blanking a workspace file from the agent:
```kdl
-// Remove the inherited skills mount
-mounts {
- source "/dev/null"
- target "/root/.pi/agent/skills"
-}
-
-// Hide the repo's .envrc from the agent
mounts {
- source "/dev/null"
- target ".envrc"
+ // Remove the inherited skills mount
+ "/dev/null" target="/root/.pi/agent/skills"
+ // Hide the repo's .envrc from the agent
+ "/dev/null" target=".envrc"
}
```
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 8c07251..2798cf6 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -108,7 +108,7 @@ profile "claude-bedrock" {
CLAUDE_CODE_USE_BEDROCK "1"
AWS_PROFILE // bare = pass through the host value
}
- mounts { source "~/.aws" }
+ mounts { "~/.aws" }
}
profile "pi-glm" {
@@ -158,6 +158,18 @@ env {
}
```
+`mounts` follows the same one-block shape: one child node per mount, whose
+name is the host source path, with optional `target` and `writable`
+properties. (The original one-block-per-mount form — `mounts { source
+"..." }` repeated — was dropped as unergonomic.)
+
+```kdl
+mounts {
+ "~/.local/share/ranger" writable=#true
+ "~/datasets" target="/root/datasets"
+}
+```
+
Layers, lowest to highest precedence:
1. **binary** — staples (`~/.config/git`, `~/.config/jj`, read-only,
diff --git a/src/config.rs b/src/config.rs
index b794287..f6d9927 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -532,7 +532,7 @@ fn parse_config(content: &str) -> Result<RawConfig> {
let mut raw = RawConfig::default();
for node in doc.nodes() {
match node.name().value() {
- "mounts" => raw.mounts.push(parse_mount(node)?),
+ "mounts" => raw.mounts.extend(parse_mounts(node)?),
"env" => raw.env.extend(parse_env(node)?),
// `profile "name" { ... }` defines; `profile "name"` selects.
"profile" => match parse_profile(node)? {
@@ -571,7 +571,7 @@ fn parse_profile(node: &KdlNode) -> Result<ProfileNode> {
match child.name().value() {
"agent" => agent = Some(Agent::parse(&single_string_arg(child)?)?),
"env" => env.extend(parse_env(child)?),
- "mounts" => mounts.push(parse_mount(child)?),
+ "mounts" => mounts.extend(parse_mounts(child)?),
other => bail!("unknown `profile` field `{other}`"),
}
}
@@ -585,28 +585,70 @@ fn parse_profile(node: &KdlNode) -> Result<ProfileNode> {
}))
}
-fn parse_mount(node: &KdlNode) -> Result<Mount> {
+/// Parse a `mounts` block. Exactly one syntax, mirroring `env`: a block with
+/// one child node per mount, whose name is the host source path, with
+/// optional `target` and `writable` properties.
+fn parse_mounts(node: &KdlNode) -> Result<Vec<Mount>> {
if !node.entries().is_empty() {
- bail!("`mounts` takes a block of child nodes, not inline values");
+ bail!("`mounts` takes a block (mounts {{ \"~/path\" }}), not inline values");
+ }
+ let Some(children) = node.children() else {
+ return Ok(Vec::new());
+ };
+ children.nodes().iter().map(parse_mount).collect()
+}
+
+fn parse_mount(node: &KdlNode) -> Result<Mount> {
+ let source = node.name().value().to_string();
+ // Catch the retired one-block-per-mount form (`mounts { source "..." }`)
+ // and point at the current shape instead of misparsing its field names
+ // as source paths.
+ if matches!(source.as_str(), "source" | "target" | "writable") {
+ bail!(
+ "`{source}` is not a mount source: each mount is one node, \
+ e.g. mounts {{ \"~/path\" target=\"/container/path\" writable=#true }}"
+ );
+ }
+ if node.children().is_some() {
+ bail!("mount \"{source}\" takes properties (target=\"...\", writable=#true), not a block");
}
- let children = node
- .children()
- .ok_or_else(|| miette!("`mounts` requires a block with a `source`"))?;
- let mut source = None;
let mut target = None;
let mut writable = false;
- for child in children.nodes() {
- match child.name().value() {
- "source" => source = Some(single_string_arg(child)?),
- "target" => target = Some(single_string_arg(child)?),
- "writable" => writable = bool_flag(child)?,
- other => bail!("unknown `mounts` field `{other}`"),
+ for entry in node.entries() {
+ let Some(name) = entry.name() else {
+ if entry.value().as_string() == Some("writable") {
+ bail!("mount \"{source}\": write writable=#true to allow writes");
+ }
+ bail!(
+ "mount \"{source}\": unexpected argument {}; the container path \
+ goes in target=\"...\"",
+ entry.value()
+ );
+ };
+ match name.value() {
+ "target" => match entry.value().as_string() {
+ Some(s) => target = Some(s.to_string()),
+ None => bail!(
+ "mount \"{source}\": `target` takes a string, got {}",
+ entry.value()
+ ),
+ },
+ "writable" => match entry.value().as_bool() {
+ Some(b) => writable = b,
+ None => bail!(
+ "mount \"{source}\": `writable` takes #true or #false, got {}",
+ entry.value()
+ ),
+ },
+ other => bail!(
+ "mount \"{source}\": unknown property `{other}` (expected `target` or `writable`)"
+ ),
}
}
Ok(Mount {
- source: source.ok_or_else(|| miette!("`mounts` block is missing `source`"))?,
+ source,
target,
writable,
})
@@ -659,21 +701,6 @@ fn optional_string_arg(node: &KdlNode) -> Result<Option<String>> {
}
}
-/// A boolean flag node: bare means true, or one boolean argument.
-fn bool_flag(node: &KdlNode) -> Result<bool> {
- match node.entries() {
- [] => Ok(true),
- [entry] if entry.name().is_none() => entry.value().as_bool().ok_or_else(|| {
- miette!(
- "`{}` takes a boolean value, got {}",
- node.name().value(),
- entry.value()
- )
- }),
- _ => bail!("`{}` takes at most one boolean value", node.name().value()),
- }
-}
-
// ---------------------------------------------------------------------------
// Builtin mounts and target resolution
// ---------------------------------------------------------------------------
@@ -786,23 +813,14 @@ mod tests {
}
#[test]
- fn parse_mounts() {
+ fn parse_mounts_block() {
let raw = parse_config(
r#"
mounts {
- source "~/.config/git"
- }
- mounts {
- source "~/.local/share/ranger"
- writable
- }
- mounts {
- source "~/Downloads"
- target "/root/downloads"
- }
- mounts {
- source "/x"
- writable #false
+ "~/.config/git"
+ "~/.local/share/ranger" writable=#true
+ "~/Downloads" target="/root/downloads"
+ "/x" writable=#false
}
"#,
)
@@ -850,24 +868,31 @@ mod tests {
}
#[test]
- fn unknown_mount_field_is_rejected() {
- let result = parse_config(
- r#"
- mounts {
- source "/x"
- bogus "y"
- }
- "#,
- );
+ fn unknown_mount_property_is_rejected() {
+ let result = parse_config(r#"mounts { "/x" bogus="y" }"#);
assert!(result.is_err());
}
#[test]
- fn mount_without_source_is_rejected() {
- let result = parse_config("mounts {\ntarget \"/x\"\n}");
+ fn mount_positional_argument_is_rejected() {
+ // The target goes in a property, not a bare argument.
+ let result = parse_config(r#"mounts { "/x" "/y" }"#);
assert!(result.is_err());
}
+ #[test]
+ fn mount_block_form_is_rejected() {
+ // The old one-block-per-mount form is gone: one shape, no synonyms.
+ let err = parse_config(r#"mounts { source "/x" target "/y" }"#).unwrap_err();
+ assert!(err.to_string().contains("not a mount source"), "{err}");
+ }
+
+ #[test]
+ fn mount_bare_writable_suggests_the_property() {
+ let err = parse_config(r#"mounts { "/x" writable }"#).unwrap_err();
+ assert!(err.to_string().contains("writable=#true"), "{err}");
+ }
+
#[test]
fn invalid_syntax_is_rejected() {
let result = parse_config("mounts { missing closing brace");
@@ -887,8 +912,8 @@ mod tests {
#[test]
fn duplicate_mount_target_within_layer_is_an_error() {
let mut builder = LayerBuilder::new(Scope::User, None);
- let a = parse_config("mounts {\nsource \"/tmp\"\ntarget \"/t\"\n}").unwrap();
- let b = parse_config("mounts {\nsource \"/dev/null\"\ntarget \"/t\"\n}").unwrap();
+ let a = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
+ let b = parse_config(r#"mounts { "/dev/null" target="/t" }"#).unwrap();
builder.add(a, Path::new("a.kdl"), WS).unwrap();
let err = builder.add(b, Path::new("b.kdl"), WS).unwrap_err();
assert!(err.to_string().contains("/t"), "{err}");
@@ -897,8 +922,8 @@ mod tests {
#[test]
fn missing_source_skips_duplicate_detection() {
let mut builder = LayerBuilder::new(Scope::User, None);
- let a = parse_config("mounts {\nsource \"/nonexistent-a\"\ntarget \"/t\"\n}").unwrap();
- let b = parse_config("mounts {\nsource \"/tmp\"\ntarget \"/t\"\n}").unwrap();
+ let a = parse_config(r#"mounts { "/nonexistent-a" target="/t" }"#).unwrap();
+ let b = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
builder.add(a, Path::new("a.kdl"), WS).unwrap();
builder.add(b, Path::new("b.kdl"), WS).unwrap();
let layer = builder.build();
@@ -1127,7 +1152,7 @@ mod tests {
fs_err::create_dir_all(&ramekin_dir).unwrap();
fs_err::write(
ramekin_dir.join("config.kdl"),
- "mounts {\nsource \"/tmp\"\ntarget \"/container/tmp\"\n}\nenv {\nFOO \"bar\"\n}\n",
+ "mounts {\n\"/tmp\" target=\"/container/tmp\"\n}\nenv {\nFOO \"bar\"\n}\n",
)
.unwrap();
@@ -1210,7 +1235,7 @@ mod tests {
CLAUDE_CODE_USE_BEDROCK "1"
AWS_PROFILE
}
- mounts { source "~/.aws" }
+ mounts { "~/.aws" }
}
"#,
)
@@ -1304,8 +1329,7 @@ mod tests {
ZHIPU_API_KEY
}
mounts {
- source "/tmp"
- target "/root/extra"
+ "/tmp" target="/root/extra"
}
}
profile "pi-glm"