Skip to main content

outrig_cli/image_setup/
init.rs

1//! `outrig image init` -- noninteractive scaffolding of a standalone image project.
2//!
3//! Unlike `outrig image add` (which writes a repo-local image-config under
4//! `.agents/outrig/images/<name>/` and mutates the repo `config.toml`), `init`
5//! creates an *independent* project whose build output is a reusable container
6//! image. It writes three files into a target directory -- `Dockerfile`,
7//! `image.toml`, and `README.md` -- and touches nothing else.
8//!
9//! The Dockerfile body is the same one `outrig image add` would render for a
10//! Debian-slim base with the filesystem MCP server. The generated `image.toml`
11//! stays beside the Dockerfile as the authoring source; `outrig image build`
12//! validates it and stamps its config into OCI labels.
13
14use std::path::Path;
15
16use crate::error::{OutrigError, Result};
17use crate::image_setup::render::{self, BaseImage, McpServer};
18use crate::paths::write_atomic;
19
20/// Scaffold a standalone image project in `dir` (relative to `cwd`, or `cwd`
21/// itself when `dir` is `None`). The project name -- used as the `image.ref`
22/// and in the README's consuming config -- is the target directory's basename.
23///
24/// Refuses to clobber any of the three generated files unless `force` is set;
25/// the existence probe runs before any write so a re-run reports the conflict
26/// without leaving a half-written project.
27pub fn run(cwd: &Path, dir: Option<&Path>, force: bool) -> Result<()> {
28    let target_dir = dir.map_or_else(|| cwd.to_path_buf(), |d| cwd.join(d));
29    let name = derive_name(&target_dir)?;
30
31    let dockerfile_path = target_dir.join("Dockerfile");
32    let image_toml_path = target_dir.join("image.toml");
33    let readme_path = target_dir.join("README.md");
34
35    if !force {
36        let existing: Vec<String> = [&dockerfile_path, &image_toml_path, &readme_path]
37            .into_iter()
38            .filter(|p| p.exists())
39            .map(|p| display_rel(p, cwd).to_string())
40            .collect();
41        if !existing.is_empty() {
42            return Err(OutrigError::Configuration(format!(
43                "{} already present; pass --force to overwrite.",
44                existing.join(", ")
45            ))
46            .into());
47        }
48    }
49
50    write_and_report(&dockerfile_path, &render_dockerfile(), cwd)?;
51    write_and_report(&image_toml_path, &render_image_toml(&name), cwd)?;
52    write_and_report(&readme_path, &render_readme(&name), cwd)?;
53
54    eprintln!("[outrig] next: build this image with `outrig image build`");
55    Ok(())
56}
57
58/// Derive the project name from the target directory's basename. `Path`'s
59/// component iterator normalizes away `.` (so `init .` and the no-arg form
60/// yield the current directory's name) but preserves a trailing `..`, for which
61/// -- and for `/` -- `file_name()` is `None` and we refuse rather than guess.
62fn derive_name(target_dir: &Path) -> Result<String> {
63    let name = target_dir
64        .file_name()
65        .and_then(|s| s.to_str())
66        .ok_or_else(|| {
67            OutrigError::Configuration(format!(
68                "could not derive a project name from {}; pass a directory name, \
69                 e.g. `outrig image init rust-dev`",
70                target_dir.display()
71            ))
72        })?;
73    if !is_valid_project_name(name) {
74        return Err(OutrigError::Configuration(format!(
75            "{name:?} is not a valid image name (must match ^[a-zA-Z][a-zA-Z0-9_-]*$); \
76             rename the directory or pass a valid name."
77        ))
78        .into());
79    }
80    Ok(name.to_string())
81}
82
83/// `^[a-zA-Z][a-zA-Z0-9_-]*$` -- the same shape `image add` documents for
84/// image names. Keeps the generated `image.toml` ref a clean token and the
85/// README's `[images.<name>]` a valid TOML bare key.
86fn is_valid_project_name(name: &str) -> bool {
87    let mut chars = name.chars();
88    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
89        && chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
90}
91
92/// Render the standalone Dockerfile: the curated Debian-slim + filesystem-MCP
93/// body from `render`.
94fn render_dockerfile() -> String {
95    render::render(BaseImage::DebianBookwormSlim, &[], &[McpServer::Fs])
96}
97
98fn render_image_toml(name: &str) -> String {
99    format!(
100        "# OutRig standalone image config. `outrig image build` validates this file\n\
101         # and stamps its MCP config into OCI labels. Required: [image].ref and a non-empty [mcp].\n\
102         \n\
103         [image]\n\
104         ref = \"{name}\"\n\
105         # description = \"...\"\n\
106         # version = \"0.1.0\"\n\
107         # tags = [\"...\"]\n\
108         \n\
109         [mcp]\n\
110         fs = {{ command = [\"mcp-server-filesystem\", \"/workspace\"] }}\n"
111    )
112}
113
114fn render_readme(name: &str) -> String {
115    // One source line per output line keeps the prose free of string-continuation
116    // whitespace surprises; lines are long but this is generated Markdown, not a doc/ page.
117    format!(
118        "# {name}\n\
119         \n\
120         An OutRig standalone toolset image. Building this project produces a container image (`{name}`) that bundles the agent's tools and MCP servers, ready to reference from any repo's OutRig config.\n\
121         \n\
122         ## Files\n\
123         \n\
124         - `Dockerfile`: the image definition. Follows OutRig conventions -- no `USER`, ends with `CMD [\"sleep\", \"infinity\"]`, and installs the host-UID bootstrap packages.\n\
125         - `image.toml`: image metadata plus the `[mcp]` table. With no `[build]` section it builds the sibling `Dockerfile` with context `.`. `outrig image build` stamps this config into OCI labels.\n\
126         - `README.md`: this file.\n\
127         \n\
128         ## Build\n\
129         \n\
130         ```sh\n\
131         outrig image build\n\
132         ```\n\
133         \n\
134         Run from this directory. It builds the `Dockerfile`, tags the image as `{name}`, stamps the `image.toml` config into OCI labels, and verifies the labeled MCP servers.\n\
135         \n\
136         ## Use it from a repo\n\
137         \n\
138         Reference the built image from a repo's `.agents/outrig/config.toml` with `image-name`. The MCP servers are declared by the image labels, so no `[images.{name}.mcp]` block is needed:\n\
139         \n\
140         ```toml\n\
141         [images.{name}]\n\
142         image-name = \"{name}\"\n\
143         ```\n"
144    )
145}
146
147fn write_and_report(path: &Path, contents: &str, cwd: &Path) -> Result<()> {
148    write_atomic(path, contents)?;
149    eprintln!("[outrig] wrote {}", display_rel(path, cwd));
150    Ok(())
151}
152
153fn display_rel<'a>(path: &'a Path, root: &Path) -> std::path::Display<'a> {
154    path.strip_prefix(root).unwrap_or(path).display()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    use outrig::config::Config;
162    use outrig::container::embedded::parse_standalone_image_toml;
163
164    use crate::mcp_self::validate::{validate_dockerfile, validate_image_toml};
165
166    fn read(path: &Path) -> String {
167        std::fs::read_to_string(path).unwrap()
168    }
169
170    #[test]
171    fn init_with_dir_arg_creates_three_files() {
172        let tmp = tempfile::tempdir().unwrap();
173        run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
174
175        let proj = tmp.path().join("rust-dev");
176        assert!(proj.join("Dockerfile").is_file());
177        assert!(proj.join("image.toml").is_file());
178        assert!(proj.join("README.md").is_file());
179        assert!(read(&proj.join("image.toml")).contains("ref = \"rust-dev\""));
180    }
181
182    #[test]
183    fn name_defaults_to_cwd_basename() {
184        let tmp = tempfile::tempdir().unwrap();
185        let cwd = tmp.path().join("web-tools");
186        // dir = None -> name from cwd basename; write_atomic creates the dir.
187        run(&cwd, None, false).unwrap();
188        assert!(read(&cwd.join("image.toml")).contains("ref = \"web-tools\""));
189    }
190
191    #[test]
192    fn init_dot_uses_cwd_basename() {
193        let tmp = tempfile::tempdir().unwrap();
194        let cwd = tmp.path().join("api-image");
195        std::fs::create_dir_all(&cwd).unwrap();
196        run(&cwd, Some(Path::new(".")), false).unwrap();
197        assert!(read(&cwd.join("image.toml")).contains("ref = \"api-image\""));
198    }
199
200    #[test]
201    fn parent_dir_arg_errors() {
202        let tmp = tempfile::tempdir().unwrap();
203        let cwd = tmp.path().join("proj");
204        std::fs::create_dir_all(&cwd).unwrap();
205        let err = run(&cwd, Some(Path::new("..")), false).unwrap_err();
206        assert!(
207            err.to_string().contains("could not derive a project name"),
208            "{err}"
209        );
210    }
211
212    #[test]
213    fn invalid_directory_name_errors() {
214        let tmp = tempfile::tempdir().unwrap();
215        let err = run(tmp.path(), Some(Path::new("123-foo")), false).unwrap_err();
216        assert!(err.to_string().contains("not a valid image name"), "{err}");
217    }
218
219    #[test]
220    fn generated_image_toml_passes_validator() {
221        let tmp = tempfile::tempdir().unwrap();
222        run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
223        let image_toml = read(&tmp.path().join("rust-dev/image.toml"));
224
225        let result = validate_image_toml(&image_toml);
226        assert!(result.valid, "{result:?}");
227        assert!(parse_standalone_image_toml(&image_toml).is_ok());
228    }
229
230    #[test]
231    fn generated_dockerfile_passes_validator() {
232        let tmp = tempfile::tempdir().unwrap();
233        run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
234        let dockerfile = read(&tmp.path().join("rust-dev/Dockerfile"));
235
236        assert_eq!(validate_dockerfile(&dockerfile).warnings, Vec::new());
237        assert!(dockerfile.starts_with("FROM docker.io/library/debian:bookworm-slim"));
238        assert!(dockerfile.contains("npm install -g @modelcontextprotocol/server-filesystem"));
239        assert!(!dockerfile.contains("COPY image.toml"), "{dockerfile}");
240
241        assert!(
242            dockerfile
243                .trim_end()
244                .ends_with("CMD [\"sleep\", \"infinity\"]")
245        );
246        assert!(!dockerfile.contains("USER "), "{dockerfile}");
247    }
248
249    #[test]
250    fn render_footer_token_is_unique() {
251        let body = render::render(BaseImage::DebianBookwormSlim, &[], &[McpServer::Fs]);
252        assert_eq!(body.matches("WORKDIR /workspace").count(), 1, "{body}");
253    }
254
255    #[test]
256    fn refuses_to_overwrite_without_force() {
257        let tmp = tempfile::tempdir().unwrap();
258        run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
259
260        let err = run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap_err();
261        let msg = err.to_string();
262        assert!(msg.contains("--force"), "{msg}");
263        assert!(msg.contains("Dockerfile"), "{msg}");
264    }
265
266    #[test]
267    fn force_overwrites_only_generated_files() {
268        let tmp = tempfile::tempdir().unwrap();
269        run(tmp.path(), Some(Path::new("rust-dev")), false).unwrap();
270
271        let proj = tmp.path().join("rust-dev");
272        let sentinel = proj.join("keep.txt");
273        std::fs::write(&sentinel, "keep me").unwrap();
274        // Clobber a generated file to prove --force regenerates it.
275        std::fs::write(proj.join("image.toml"), "garbage").unwrap();
276
277        run(tmp.path(), Some(Path::new("rust-dev")), true).unwrap();
278
279        assert_eq!(read(&sentinel), "keep me");
280        assert!(read(&proj.join("image.toml")).contains("ref = \"rust-dev\""));
281    }
282
283    #[test]
284    fn readme_documents_image_name_consuming_config() {
285        let readme = render_readme("rust-dev");
286        // The consuming-config block uses image-name with no mcp sub-block:
287        // image-name immediately follows the [images.<name>] header.
288        assert!(
289            readme.contains("[images.rust-dev]\nimage-name = \"rust-dev\""),
290            "{readme}"
291        );
292        assert!(
293            readme.contains("stamps this config into OCI labels"),
294            "{readme}"
295        );
296    }
297
298    #[test]
299    fn consuming_repo_config_is_valid() {
300        // The shape the README documents: image-name, no [images.<name>.mcp].
301        let snippet = "[images.rust-dev]\nimage-name = \"rust-dev\"\n";
302        let cfg = Config::load_from_str(snippet).expect("config parses");
303        cfg.validate(None).expect("config validates");
304    }
305}