Skip to main content

outrig_cli/cli/
design_prompt.rs

1//! `outrig design prompt` -- print a one-shot AI design prompt or MCP setup
2//! snippets for OutRig's self-description server.
3
4use 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    /// Print a self-contained prompt for AI-assisted image-config design.
21    Prompt(PromptArgs),
22}
23
24#[derive(Debug, Args)]
25pub struct PromptArgs {
26    /// Print a copy-pasteable MCP config snippet for the named AI tool.
27    #[arg(long = "print-mcp-config", value_name = "TOOL")]
28    pub print_mcp_config: Option<McpConfigTool>,
29
30    /// Print a prompt for designing a standalone image project.
31    #[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}