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
change rwuvpykprwwkumomouvuokltzkyounsp
commit adcd0af7ac8aef65f8f0d8dd312ddbc6484a30d8
author Alpha Chen <alpha@kejadlen.dev>
date
parent upslpwzq
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"