refactor: consolidate into Ramekin struct with global workspace arg
Dirs, resolve_dirs, cmd_config, and cmd_run folded into a single
Ramekin struct. workspace is now a global CLI arg instead of
repeated per subcommand. main returns Result for direct ? usage.

Assisted-by: Claude Opus 4.6 via pi
change xornozzwqoztzxtsusoktnxuulmosqkt
commit b9ee7a7b89d14059ba678df29f69685d9c951297
author Alpha Chen <alpha@kejadlen.dev>
date
parent mulrwqmu
diff --git a/src/main.rs b/src/main.rs
index 5162f13..a59daa4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,7 +3,7 @@ mod config;
 use std::collections::hash_map::DefaultHasher;
 use std::hash::{Hash, Hasher};
 use std::path::{Path, PathBuf};
-use std::process::{Command, ExitCode, Stdio};
+use std::process::{Command, Stdio};
 
 use clap::{Parser, Subcommand};
 use color_eyre::eyre::{Context, Result, bail};
@@ -19,80 +19,116 @@ const VERSION: &str = env!("RAMEKIN_VERSION");
 #[derive(Parser)]
 #[command(about = "Run a pi coding agent in a containerized environment", version = VERSION)]
 struct Cli {
-    #[command(subcommand)]
-    command: Option<Cmd>,
-
     /// Workspace directory to mount (defaults to current directory)
-    #[arg(default_value = ".")]
+    #[arg(global = true, default_value = ".")]
     workspace: PathBuf,
+
+    #[command(subcommand)]
+    command: Option<Cmd>,
 }
 
 #[derive(Subcommand)]
 enum Cmd {
     /// Start a containerized pi agent session
     Run {
-        /// Workspace directory to mount (defaults to current directory)
-        #[arg(default_value = ".")]
-        workspace: PathBuf,
-
         /// Force a full image rebuild (ignores Docker layer cache)
         #[arg(long)]
         rebuild: bool,
     },
     /// Show resolved paths and mount configuration
-    Config {
-        /// Workspace directory to resolve (defaults to current directory)
-        #[arg(default_value = ".")]
-        workspace: PathBuf,
-    },
+    Config,
 }
 
-fn main() -> ExitCode {
-    color_eyre::install().ok();
+fn main() -> Result<()> {
+    color_eyre::install()?;
     tracing_subscriber::registry()
         .with(fmt::layer())
         .with(EnvFilter::from_default_env())
         .init();
 
     let cli = Cli::parse();
-    let result = match cli.command {
-        Some(Cmd::Run { workspace, rebuild }) => cmd_run(workspace, rebuild),
-        Some(Cmd::Config { workspace }) => cmd_config(workspace),
-        None => cmd_run(cli.workspace, false),
-    };
+    let command = cli.command.unwrap_or(Cmd::Run { rebuild: false });
+    let ramekin = Ramekin::resolve(cli.workspace)?;
 
-    if let Err(e) = result {
-        error!("{e:?}");
-        return ExitCode::FAILURE;
+    match command {
+        Cmd::Run { rebuild } => ramekin.run(rebuild),
+        Cmd::Config => ramekin.config(),
     }
-
-    ExitCode::SUCCESS
 }
 
 // ---------------------------------------------------------------------------
-// Resolved paths shared by run and config
+// Ramekin
 // ---------------------------------------------------------------------------
 
-struct Dirs {
+struct Ramekin {
     workspace: PathBuf,
     agent_dir: PathBuf,
     pi_data_dir: PathBuf,
     repo_sessions_dir: PathBuf,
     cache_dir: PathBuf,
-    custom_dockerfile: PathBuf,
+    custom_dockerfile: Option<PathBuf>,
+    mounts: Vec<config::ResolvedMount>,
 }
 
-impl Dirs {
-    /// All volume mounts: builtin (always present) + config (user-configurable,
-    /// skipped when source doesn't exist).
-    fn all_mounts(&self) -> Vec<config::ResolvedMount> {
+impl Ramekin {
+    /// Resolve all paths, create XDG directories, seed default files, and
+    /// resolve mounts.
+    fn resolve(workspace_arg: PathBuf) -> Result<Self> {
+        let workspace = workspace_arg.canonicalize().wrap_err_with(|| {
+            format!("workspace path does not exist: {}", workspace_arg.display())
+        })?;
+
+        let xdg = xdg::BaseDirectories::with_prefix("ramekin");
+
+        // Agent directory mirrors /root/.pi/agent in the container.
+        let agent_dir = xdg
+            .create_config_directory("agent")
+            .wrap_err("failed to create agent config directory")?;
+
+        for file in ["settings.json", "keybindings.json"] {
+            let path = agent_dir.join(file);
+            if !path.is_file() {
+                fs_err::write(&path, "{}")?;
+            }
+        }
+        let agents_md = agent_dir.join("AGENTS.md");
+        if !agents_md.exists() {
+            fs_err::write(&agents_md, "")?;
+        }
+
+        let extensions_dir = xdg
+            .create_config_directory("agent/extensions")
+            .wrap_err("failed to create extensions directory")?;
+        fs_err::write(extensions_dir.join("ramekin.ts"), RAMEKIN_EXTENSION)?;
+
+        xdg.create_config_directory("agent/skills")
+            .wrap_err("failed to create skills directory")?;
+
+        let pi_data_dir = xdg
+            .create_data_directory("")
+            .wrap_err("failed to create pi data directory")?;
+
+        let repo_slug = repo_slug(&workspace);
+        let repo_sessions_dir = xdg
+            .create_data_directory(format!("repos/{repo_slug}/sessions"))
+            .wrap_err("failed to create repo sessions directory")?;
+
+        let cache_dir = xdg
+            .create_cache_directory("")
+            .wrap_err("failed to create cache directory")?;
+
+        let custom_dockerfile_path = workspace.join(".ramekin/Dockerfile");
+        let custom_dockerfile = custom_dockerfile_path
+            .is_file()
+            .then_some(custom_dockerfile_path);
+
+        // Builtin mounts (always present)
         let builtin = [
-            (&self.pi_data_dir, "/root/.pi"),
-            (&self.agent_dir, "/root/.pi/agent"),
-            (&self.repo_sessions_dir, "/root/.pi/agent/sessions"),
-            (&self.workspace, "/workspace"),
+            (&pi_data_dir, "/root/.pi"),
+            (&agent_dir, "/root/.pi/agent"),
+            (&repo_sessions_dir, "/root/.pi/agent/sessions"),
+            (&workspace, "/workspace"),
         ];
-
         let mut mounts: Vec<config::ResolvedMount> = builtin
             .into_iter()
             .map(|(source, target)| config::ResolvedMount {
@@ -102,223 +138,165 @@ impl Dirs {
             })
             .collect();
 
+        // Config mounts (user-configurable, skipped when source doesn't exist)
         mounts.extend(config::Config::default().resolve_mounts());
-        mounts
-    }
-}
-
-/// Resolve all XDG directories and ensure the agent directory structure exists.
-fn resolve_dirs(workspace_arg: PathBuf) -> Result<Dirs> {
-    let workspace = workspace_arg
-        .canonicalize()
-        .wrap_err_with(|| format!("workspace path does not exist: {}", workspace_arg.display()))?;
-
-    let xdg = xdg::BaseDirectories::with_prefix("ramekin");
 
-    // User-scoped: agent directory mirrors /root/.pi/agent in the container.
-    // Skills, extensions, settings, keybindings, and AGENTS.md all live here.
-    let agent_dir = xdg
-        .create_config_directory("agent")
-        .wrap_err("failed to create agent config directory")?;
-
-    for file in ["settings.json", "keybindings.json"] {
-        let path = agent_dir.join(file);
-        if !path.is_file() {
-            fs_err::write(&path, "{}")?;
-        }
-    }
-    let agents_md = agent_dir.join("AGENTS.md");
-    if !agents_md.exists() {
-        fs_err::write(&agents_md, "")?;
+        Ok(Self {
+            workspace,
+            agent_dir,
+            pi_data_dir,
+            repo_sessions_dir,
+            cache_dir,
+            custom_dockerfile,
+            mounts,
+        })
     }
 
-    let extensions_dir = xdg
-        .create_config_directory("agent/extensions")
-        .wrap_err("failed to create extensions directory")?;
-    fs_err::write(extensions_dir.join("ramekin.ts"), RAMEKIN_EXTENSION)?;
-
-    xdg.create_config_directory("agent/skills")
-        .wrap_err("failed to create skills directory")?;
-
-    let pi_data_dir = xdg
-        .create_data_directory("")
-        .wrap_err("failed to create pi data directory")?;
-
-    let repo_slug = repo_slug(&workspace);
-    let repo_sessions_dir = xdg
-        .create_data_directory(format!("repos/{repo_slug}/sessions"))
-        .wrap_err("failed to create repo sessions directory")?;
-
-    let cache_dir = xdg
-        .create_cache_directory("")
-        .wrap_err("failed to create cache directory")?;
-
-    let custom_dockerfile = workspace.join(".ramekin/Dockerfile");
-
-    Ok(Dirs {
-        workspace,
-        agent_dir,
-        pi_data_dir,
-        repo_sessions_dir,
-        cache_dir,
-        custom_dockerfile,
-    })
-}
+    fn config(&self) -> Result<()> {
+        let check = |path: &Path| if path.exists() { "✓" } else { "✗" };
 
-// ---------------------------------------------------------------------------
-// config subcommand
-// ---------------------------------------------------------------------------
+        println!("Workspace");
+        println!("  {} {}", check(&self.workspace), self.workspace.display());
 
-fn cmd_config(workspace: PathBuf) -> Result<()> {
-    let dirs = resolve_dirs(workspace)?;
-
-    let check = |path: &Path| if path.exists() { "✓" } else { "✗" };
-
-    println!("Workspace");
-    println!("  {} {}", check(&dirs.workspace), dirs.workspace.display());
-
-    println!();
-    println!("Ramekin directories");
-    println!(
-        "  {} agent    {}",
-        check(&dirs.agent_dir),
-        dirs.agent_dir.display()
-    );
-    println!(
-        "  {} data     {}",
-        check(&dirs.pi_data_dir),
-        dirs.pi_data_dir.display()
-    );
-    println!(
-        "  {} sessions {}",
-        check(&dirs.repo_sessions_dir),
-        dirs.repo_sessions_dir.display()
-    );
-    println!(
-        "  {} cache    {}",
-        check(&dirs.cache_dir),
-        dirs.cache_dir.display()
-    );
-
-    println!();
-    println!("Volume mounts");
-    for m in dirs.all_mounts() {
+        println!();
+        println!("Ramekin directories");
         println!(
-            "  {} {} → {}",
-            check(&m.source),
-            m.source.display(),
-            m.display_target()
+            "  {} agent    {}",
+            check(&self.agent_dir),
+            self.agent_dir.display()
+        );
+        println!(
+            "  {} data     {}",
+            check(&self.pi_data_dir),
+            self.pi_data_dir.display()
+        );
+        println!(
+            "  {} sessions {}",
+            check(&self.repo_sessions_dir),
+            self.repo_sessions_dir.display()
+        );
+        println!(
+            "  {} cache    {}",
+            check(&self.cache_dir),
+            self.cache_dir.display()
         );
-    }
-
-    println!();
-    println!("Dockerfile");
-    if dirs.custom_dockerfile.is_file() {
-        println!("  ✓ {}", dirs.custom_dockerfile.display());
-    } else {
-        println!("  embedded (default)");
-        println!("  ✗ {} (not found)", dirs.custom_dockerfile.display());
-    }
-
-    Ok(())
-}
-
-// ---------------------------------------------------------------------------
-// run subcommand
-// ---------------------------------------------------------------------------
-
-fn cmd_run(workspace: PathBuf, rebuild: bool) -> Result<()> {
-    let dirs = resolve_dirs(workspace)?;
-
-    info!(agent = %dirs.agent_dir.display(), repo = %dirs.repo_sessions_dir.display(), "directories");
-    info!(workspace = %dirs.workspace.display(), "starting agent");
 
-    fs_err::write(dirs.cache_dir.join("Dockerfile"), DOCKERFILE)?;
+        println!();
+        println!("Volume mounts");
+        for m in &self.mounts {
+            println!(
+                "  {} {} → {}",
+                check(&m.source),
+                m.source.display(),
+                m.display_target()
+            );
+        }
 
-    // Build the base image (--no-cache --pull when --rebuild is set)
-    if rebuild {
-        info!("rebuilding base image (no cache)");
-    } else {
-        info!("building base image");
-    }
-    let base_dockerfile = dirs.cache_dir.join("Dockerfile");
-    let mut build_cmd = Command::new("docker");
-    build_cmd
-        .args(["build", "-t", "ramekin-agent", "-f"])
-        .arg(&base_dockerfile);
-    if rebuild {
-        build_cmd.args(["--no-cache", "--pull"]);
-    }
-    build_cmd.arg(&dirs.cache_dir);
-    let status = build_cmd.status().wrap_err("failed to build base image")?;
+        println!();
+        println!("Dockerfile");
+        match &self.custom_dockerfile {
+            Some(path) => println!("  ✓ {}", path.display()),
+            None => {
+                println!("  embedded (default)");
+                println!(
+                    "  ✗ {} (not found)",
+                    self.workspace.join(".ramekin/Dockerfile").display()
+                );
+            }
+        }
 
-    if !status.success() {
-        bail!("base image build failed ({})", status);
+        Ok(())
     }
 
-    // If a project Dockerfile exists, build it on top of the base image
-    let (dockerfile, build_context) = if dirs.custom_dockerfile.exists() {
-        info!("building project image from .ramekin/Dockerfile");
-        (dirs.custom_dockerfile.clone(), dirs.workspace.clone())
-    } else {
-        (base_dockerfile, dirs.cache_dir.clone())
-    };
-
-    // Session-scoped: unique compose file and project name
-    let xdg = xdg::BaseDirectories::with_prefix("ramekin");
-    let session_id = session_id();
-    let session_dir = xdg
-        .create_cache_directory(format!("sessions/{session_id}"))
-        .wrap_err("failed to create session directory")?;
-
-    let compose = generate_compose(&dockerfile, &build_context, &dirs.all_mounts());
-    let compose_file = session_dir.join("compose.yml");
-    fs_err::write(&compose_file, &compose)?;
-
-    let project_name = format!("ramekin-{session_id}");
-    let docker_compose = |args: &[&str]| -> Result<Command> {
-        let mut cmd = Command::new("docker");
-        cmd.args(["compose", "-f"])
-            .arg(&compose_file)
-            .args(["--project-name", &project_name])
-            .args(args);
-        Ok(cmd)
-    };
+    fn run(&self, rebuild: bool) -> Result<()> {
+        info!(agent = %self.agent_dir.display(), repo = %self.repo_sessions_dir.display(), "directories");
+        info!(workspace = %self.workspace.display(), "starting agent");
 
-    let status = docker_compose(&["up", "-d", "--build"])?
-        .status()
-        .wrap_err("failed to run docker compose up")?;
+        // Write the embedded Dockerfile to the cache directory
+        let base_dockerfile = self.cache_dir.join("Dockerfile");
+        fs_err::write(&base_dockerfile, DOCKERFILE)?;
 
-    if !status.success() {
-        bail!("docker compose up failed ({})", status);
-    }
+        // Build the base image
+        if rebuild {
+            info!("rebuilding base image (no cache)");
+        } else {
+            info!("building base image");
+        }
+        let mut build_cmd = Command::new("docker");
+        build_cmd
+            .args(["build", "-t", "ramekin-agent", "-f"])
+            .arg(&base_dockerfile);
+        if rebuild {
+            build_cmd.args(["--no-cache", "--pull"]);
+        }
+        build_cmd.arg(&self.cache_dir);
+        let status = build_cmd.status().wrap_err("failed to build base image")?;
+        if !status.success() {
+            bail!("base image build failed ({})", status);
+        }
 
-    let status = docker_compose(&["attach", "agent"])?
-        .stdin(Stdio::inherit())
-        .stdout(Stdio::inherit())
-        .stderr(Stdio::inherit())
-        .status()
-        .wrap_err("failed to attach to agent")?;
+        // Determine the final dockerfile and build context
+        let (dockerfile, build_context) = match &self.custom_dockerfile {
+            Some(custom) => {
+                info!("building project image from .ramekin/Dockerfile");
+                (custom.clone(), self.workspace.clone())
+            }
+            None => (base_dockerfile, self.cache_dir.clone()),
+        };
+
+        // Session-scoped: unique compose file and project name
+        let xdg = xdg::BaseDirectories::with_prefix("ramekin");
+        let session_id = session_id();
+        let session_dir = xdg
+            .create_cache_directory(format!("sessions/{session_id}"))
+            .wrap_err("failed to create session directory")?;
+
+        let compose = generate_compose(&dockerfile, &build_context, &self.mounts);
+        let compose_file = session_dir.join("compose.yml");
+        fs_err::write(&compose_file, &compose)?;
+
+        let project_name = format!("ramekin-{session_id}");
+        let docker_compose = |args: &[&str]| -> Result<Command> {
+            let mut cmd = Command::new("docker");
+            cmd.args(["compose", "-f"])
+                .arg(&compose_file)
+                .args(["--project-name", &project_name])
+                .args(args);
+            Ok(cmd)
+        };
+
+        let status = docker_compose(&["up", "-d", "--build"])?
+            .status()
+            .wrap_err("failed to run docker compose up")?;
+        if !status.success() {
+            bail!("docker compose up failed ({})", status);
+        }
 
-    // Always tear down, regardless of attach exit status
-    let down_status = docker_compose(&["down"])?
-        .status()
-        .wrap_err("failed to run docker compose down")?;
+        let status = docker_compose(&["attach", "agent"])?
+            .stdin(Stdio::inherit())
+            .stdout(Stdio::inherit())
+            .stderr(Stdio::inherit())
+            .status()
+            .wrap_err("failed to attach to agent")?;
+
+        // Always tear down, regardless of attach exit status
+        let down_status = docker_compose(&["down"])?
+            .status()
+            .wrap_err("failed to run docker compose down")?;
+        if !down_status.success() {
+            error!("docker compose down failed ({})", down_status);
+        }
 
-    if !down_status.success() {
-        error!("docker compose down failed ({})", down_status);
-    }
+        if let Err(e) = fs_err::remove_dir_all(&session_dir) {
+            error!("failed to clean up session dir: {e}");
+        }
 
-    // Clean up session directory
-    if let Err(e) = fs_err::remove_dir_all(&session_dir) {
-        error!("failed to clean up session dir: {e}");
-    }
+        if !status.success() {
+            bail!("agent exited with error ({})", status);
+        }
 
-    if !status.success() {
-        bail!("agent exited with error ({})", status);
+        Ok(())
     }
-
-    Ok(())
 }
 
 // ---------------------------------------------------------------------------