1use std::fmt::Write as _;
5use std::io::Write as _;
6
7use clap::{Args, Subcommand, ValueEnum};
8
9use crate::error::Result;
10use crate::mcp_self::docs;
11
12#[derive(Debug, Args)]
13pub struct DesignArgs {
14 #[command(subcommand)]
15 pub cmd: DesignCommand,
16}
17
18#[derive(Debug, Subcommand)]
19pub enum DesignCommand {
20 Prompt(PromptArgs),
22}
23
24#[derive(Debug, Args)]
25pub struct PromptArgs {
26 #[arg(long = "print-mcp-config", value_name = "TOOL")]
28 pub print_mcp_config: Option<McpConfigTool>,
29
30 #[arg(long)]
32 pub standalone: bool,
33}
34
35#[derive(Debug, Clone, Copy, ValueEnum)]
36#[value(rename_all = "kebab-case")]
37pub enum McpConfigTool {
38 ClaudeCode,
39 ClaudeDesktop,
40 Codex,
41 Cursor,
42}
43
44pub fn execute(args: &DesignArgs) -> Result<i32> {
45 let output = match &args.cmd {
46 DesignCommand::Prompt(args) => match args.print_mcp_config {
47 Some(tool) => mcp_config_snippet(tool).to_string(),
48 None if args.standalone => render_standalone_prompt(),
49 None => render_prompt(),
50 },
51 };
52 write_stdout(&output)?;
53 Ok(0)
54}
55
56fn write_stdout(output: &str) -> Result<()> {
57 let mut stdout = std::io::stdout().lock();
58 stdout.write_all(output.as_bytes())?;
59 if !output.ends_with('\n') {
60 stdout.write_all(b"\n")?;
61 }
62 stdout.flush()?;
63 Ok(())
64}
65
66pub(crate) fn render_prompt() -> String {
67 let mut out = String::new();
68 let _ = writeln!(out, "# OutRig Container-Config Design Prompt");
69 let _ = writeln!(out);
70 let _ = writeln!(
71 out,
72 "You are designing an image-config for OutRig version {}.",
73 env!("CARGO_PKG_VERSION")
74 );
75 out.push_str(
76 "\n\
77 OutRig runs LLM agents with MCP servers inside podman containers. \
78 Produce a Dockerfile and a matching `.agents/outrig/config.toml` \
79 `[images.<name>]` block. Respect these rules:\n\
80 \n\
81 - Keep the container alive with `CMD [\"sleep\", \"infinity\"]`.\n\
82 - Do not add a Dockerfile `USER`; OutRig maps the host UID/GID at runtime.\n\
83 - Install every MCP server binary in the image or ensure it is on `PATH`.\n\
84 - Prefer `/workspace` as the mounted repo path unless the request says otherwise.\n\
85 - Return exact file paths and complete file contents.\n\
86 - Check the proposed Dockerfile and TOML against the documentation below.\n\
87 \n\
88 Read the bundled OutRig documentation and examples before designing.\n",
89 );
90
91 append_bundled_docs(&mut out);
92
93 out.push_str("## Worked Examples\n\n");
94 out.push_str(RUST_EXAMPLE.trim());
95 out.push_str("\n\n");
96 out.push_str(NODE_EXAMPLE.trim());
97 out.push_str("\n\n");
98 out.push_str(MULTI_MCP_EXAMPLE.trim());
99 out.push('\n');
100 out
101}
102
103pub(crate) fn render_standalone_prompt() -> String {
104 let mut out = String::new();
105 let _ = writeln!(out, "# OutRig Standalone Image Project Design Prompt");
106 let _ = writeln!(out);
107 let _ = writeln!(
108 out,
109 "You are designing a standalone image project for OutRig version {}.",
110 env!("CARGO_PKG_VERSION")
111 );
112 out.push_str(
113 "\n\
114 A standalone project builds one reusable, labeled container image. \
115 Produce complete file contents for `Dockerfile`, `image.toml`, and \
116 `README.md`. Respect these rules:\n\
117 \n\
118 - `image.toml` requires `[image].ref` and a non-empty `[mcp]` table.\n\
119 - `[image].description`, `[image].version`, and `[image].tags` are optional.\n\
120 - `[build]` is optional. When present, it must set both `dockerfile` and `context`.\n\
121 - Without `[build]`, `outrig image build` uses sibling `Dockerfile` and context `.`.\n\
122 - Keep the container alive with `CMD [\"sleep\", \"infinity\"]`.\n\
123 - Do not add a Dockerfile `USER`; OutRig maps the host UID/GID at runtime.\n\
124 - Install every MCP server binary in the image or ensure it is on `PATH`.\n\
125 - `outrig image build` validates `image.toml` and stamps the config into OCI labels.\n\
126 - The Dockerfile must not copy `image.toml` or any OutRig config file into the image.\n\
127 - Return exact file paths and complete file contents.\n\
128 - Check the proposed Dockerfile and `image.toml` against the documentation below.\n\
129 \n\
130 Read the bundled OutRig documentation and example before designing.\n",
131 );
132
133 append_bundled_docs(&mut out);
134
135 out.push_str("## Worked Example\n\n");
136 out.push_str(STANDALONE_EXAMPLE.trim());
137 out.push('\n');
138 out
139}
140
141fn append_bundled_docs(out: &mut String) {
142 out.push_str("\n## Bundled OutRig Docs\n\n");
143 for doc in docs::DOCS {
144 let _ = writeln!(out, "### doc/{}.md", doc.page);
145 let _ = writeln!(out);
146 out.push_str(doc.markdown.trim_end());
147 out.push_str("\n\n");
148 }
149}
150
151fn mcp_config_snippet(tool: McpConfigTool) -> &'static str {
152 match tool {
153 McpConfigTool::ClaudeCode => "claude mcp add outrig-self -- outrig mcp self\n",
154 McpConfigTool::ClaudeDesktop => {
155 r#"{
156 "mcpServers": {
157 "outrig-self": {
158 "command": "outrig",
159 "args": ["mcp", "self"]
160 }
161 }
162}
163"#
164 }
165 McpConfigTool::Codex => {
166 r#"[mcp_servers.outrig-self]
167command = "outrig"
168args = ["mcp", "self"]
169"#
170 }
171 McpConfigTool::Cursor => {
172 r#"{
173 "mcpServers": {
174 "outrig-self": {
175 "type": "stdio",
176 "command": "outrig",
177 "args": ["mcp", "self"]
178 }
179 }
180}
181"#
182 }
183 }
184}
185
186const RUST_EXAMPLE: &str = r#"
187### Worked example: Rust container
188
189User request: Rust development with filesystem and shell tools.
190
191```Dockerfile
192FROM docker.io/library/debian:bookworm-slim
193RUN apt-get update \
194 && apt-get install -y --no-install-recommends ca-certificates curl git build-essential nodejs npm passwd \
195 && rm -rf /var/lib/apt/lists/*
196RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
197 | sh -s -- -y --default-toolchain stable --profile default
198ENV PATH=/root/.cargo/bin:$PATH
199RUN npm install -g @modelcontextprotocol/server-filesystem
200WORKDIR /workspace
201CMD ["sleep", "infinity"]
202```
203
204```toml
205[images.rust-dev]
206dockerfile = ".agents/outrig/images/rust-dev/Dockerfile"
207context = ".agents/outrig/images/rust-dev"
208
209 [images.rust-dev.mcp]
210 fs = { command = ["mcp-server-filesystem", "/workspace"] }
211 shell = ["bash", "-lc", "exec shell-mcp-command"]
212```
213"#;
214
215const NODE_EXAMPLE: &str = r#"
216### Worked example: Node container
217
218User request: Node 20 container with repo filesystem access.
219
220```Dockerfile
221FROM docker.io/library/node:20-bookworm-slim
222RUN apt-get update \
223 && apt-get install -y --no-install-recommends git passwd \
224 && rm -rf /var/lib/apt/lists/*
225RUN npm install -g @modelcontextprotocol/server-filesystem
226WORKDIR /workspace
227CMD ["sleep", "infinity"]
228```
229
230```toml
231[images.node-dev]
232dockerfile = ".agents/outrig/images/node-dev/Dockerfile"
233context = ".agents/outrig/images/node-dev"
234
235 [images.node-dev.mcp]
236 fs = { command = ["mcp-server-filesystem", "/workspace"] }
237```
238"#;
239
240const MULTI_MCP_EXAMPLE: &str = r#"
241### Worked example: Multi-MCP container
242
243User request: Filesystem, git, and a project-specific build MCP.
244
245```Dockerfile
246FROM docker.io/library/python:3.12-slim
247RUN apt-get update \
248 && apt-get install -y --no-install-recommends git nodejs npm passwd \
249 && rm -rf /var/lib/apt/lists/*
250RUN npm install -g @modelcontextprotocol/server-filesystem
251RUN pip install --break-system-packages mcp-server-git
252RUN pip install --break-system-packages project-build-mcp
253WORKDIR /workspace
254CMD ["sleep", "infinity"]
255```
256
257```toml
258[images.tools]
259dockerfile = ".agents/outrig/images/tools/Dockerfile"
260context = ".agents/outrig/images/tools"
261
262 [images.tools.mcp]
263 fs = { command = ["mcp-server-filesystem", "/workspace"] }
264 git = { command = ["mcp-server-git", "--repository", "/workspace"] }
265 build = { command = ["project-build-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }
266```
267"#;
268
269const STANDALONE_EXAMPLE: &str = r#"
270### Worked example: Standalone Rust toolset image
271
272User request: reusable Rust development image with filesystem and git MCP servers.
273
274`Dockerfile`:
275
276```Dockerfile
277FROM docker.io/library/debian:bookworm-slim
278RUN apt-get update \
279 && apt-get install -y --no-install-recommends ca-certificates curl git build-essential nodejs npm python3-pip passwd \
280 && rm -rf /var/lib/apt/lists/*
281RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
282 | sh -s -- -y --default-toolchain stable --profile default
283ENV PATH=/root/.cargo/bin:$PATH
284RUN npm install -g @modelcontextprotocol/server-filesystem
285RUN pip install --break-system-packages mcp-server-git
286WORKDIR /workspace
287CMD ["sleep", "infinity"]
288```
289
290`image.toml`:
291
292```toml
293[image]
294ref = "rust-toolset:0.1.0"
295description = "Reusable Rust development image for OutRig"
296version = "0.1.0"
297tags = ["rust", "git"]
298
299[mcp]
300fs = { command = ["mcp-server-filesystem", "/workspace"] }
301git = { command = ["mcp-server-git", "--repository", "/workspace"] }
302```
303
304`README.md`:
305
306````markdown
307# rust-toolset
308
309Reusable OutRig image for Rust development.
310
311## Build
312
313```sh
314outrig image build
315```
316
317The build reads `image.toml`, validates the `[mcp]` table, stamps the config into OCI labels,
318and tags the image as `rust-toolset:0.1.0`.
319
320## Use from a repo
321
322```toml
323[images.rust-toolset]
324image-name = "rust-toolset:0.1.0"
325```
326
327The MCP servers are declared by the image labels, so the repo config does not need an
328`[images.rust-toolset.mcp]` block.
329````
330"#;
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn repo_local_prompt_contains_version_docs_and_examples() {
338 let prompt = render_prompt();
339 assert!(prompt.contains(env!("CARGO_PKG_VERSION")));
340 assert!(prompt.contains("# OutRig Container-Config Design Prompt"));
341 assert!(prompt.contains("`.agents/outrig/config.toml`"));
342 assert!(prompt.contains("# Containers"));
343 assert!(prompt.contains("# Config Reference"));
344 assert!(prompt.contains("### Worked example: Rust container"));
345 assert!(prompt.contains("### Worked example: Multi-MCP container"));
346 }
347
348 #[test]
349 fn standalone_prompt_contains_schema_conventions_and_example() {
350 let prompt = render_standalone_prompt();
351 for marker in [
352 "# OutRig Standalone Image Project Design Prompt",
353 "`Dockerfile`, `image.toml`, and `README.md`",
354 "`image.toml` requires `[image].ref` and a non-empty `[mcp]` table",
355 "`[image].description`, `[image].version`, and `[image].tags` are optional",
356 "`[build]` is optional",
357 "CMD [\"sleep\", \"infinity\"]",
358 "Do not add a Dockerfile `USER`",
359 "ensure it is on `PATH`",
360 "stamps the config into OCI labels",
361 "must not copy `image.toml`",
362 "### Worked example: Standalone Rust toolset image",
363 "[images.rust-toolset]",
364 "image-name = \"rust-toolset:0.1.0\"",
365 ] {
366 assert!(prompt.contains(marker), "prompt lacked {marker:?}");
367 }
368 assert!(prompt.contains("# Containers"));
369 assert!(prompt.contains("# Config Reference"));
370 }
371
372 #[test]
373 fn snippets_are_newline_terminated() {
374 for tool in [
375 McpConfigTool::ClaudeCode,
376 McpConfigTool::ClaudeDesktop,
377 McpConfigTool::Codex,
378 McpConfigTool::Cursor,
379 ] {
380 assert!(mcp_config_snippet(tool).ends_with('\n'));
381 }
382 }
383}