Skip to main content

outrig_cli/image_setup/
add.rs

1//! `outrig image add` -- interactive scaffolding of a new image-config.
2//!
3//! Walks the user through name, base image, toolchains, and MCP servers, then
4//! writes `.agents/outrig/images/<name>/Dockerfile` and appends matching
5//! `[images.<name>]` and `[images.<name>.mcp]` blocks to the repo
6//! `config.toml`. The TOML mutation goes through `toml_edit` so any
7//! surrounding comments and formatting survive.
8//!
9//! `run` constructs real terminal I/O; `run_with` is the test seam that takes
10//! an arbitrary `PromptSource`.
11
12use std::path::Path;
13
14use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
15
16use crate::error::{OutrigError, Result};
17use crate::image_setup::render::{self, BaseImage, McpServer, Toolchain};
18use crate::init::prompt::{self, Field, PromptSource};
19use crate::init::repo as init_repo;
20use crate::paths::{global_config_path, image_dir, image_dir_rel, repo_config_path, write_atomic};
21
22/// CLI entry point. Resolves the repo root from `cwd` (walking up, with a
23/// fallback prompt to bootstrap a fresh `.agents/outrig/config.toml` if
24/// none is found) before running the interactive image-add flow. One
25/// `PromptSource` is threaded through both halves so the user sees a
26/// single conversation. `global_override` plumbs `--global-config` into
27/// the bootstrap path so the model-section can list models from the
28/// right global config.
29pub async fn run(
30    cwd: &Path,
31    global_override: Option<&Path>,
32    name: Option<String>,
33    force: bool,
34) -> Result<()> {
35    let global_path = global_config_path(global_override);
36    let mut prompt = prompt::auto();
37    let mut hf = crate::hf::auto();
38    let (repo_root, bootstrapped_name) =
39        init_repo::resolve_or_bootstrap(cwd, &global_path, &mut prompt, &mut hf).await?;
40    // CLI-provided name wins; otherwise reuse whatever the bootstrap
41    // already asked for.
42    let effective = name.or(bootstrapped_name);
43    run_with(&repo_root, effective, force, &mut prompt).await
44}
45
46/// Drives the interactive flow against an arbitrary `PromptSource`.
47///
48/// Idempotency probe (Dockerfile path + existing `[images.<name>]`
49/// block) runs *before* any prompts so an accidental re-run doesn't burn
50/// through the user's input before bailing.
51pub async fn run_with(
52    repo_root: &Path,
53    name_arg: Option<String>,
54    force: bool,
55    prompt: &mut impl PromptSource,
56) -> Result<()> {
57    let cfg_path = repo_config_path(repo_root);
58
59    let name = match name_arg {
60        Some(n) => n,
61        None => {
62            let default = init_repo::default_image_name(repo_root);
63            prompt.ask_string(&NAME_FIELD, &default).await?
64        }
65    };
66
67    let dockerfile_path = image_dir(repo_root, &name).join("Dockerfile");
68    let mut doc = load_doc(&cfg_path)?;
69
70    if !force {
71        if dockerfile_path.exists() {
72            return Err(OutrigError::Configuration(format!(
73                "{} already exists; pass --force to overwrite.",
74                dockerfile_path.display()
75            ))
76            .into());
77        }
78        if image_block_exists(&doc, &name) {
79            return Err(OutrigError::Configuration(format!(
80                "[images.{name}] already exists in {}; pass --force to overwrite.",
81                cfg_path.display()
82            ))
83            .into());
84        }
85    }
86
87    let base_idx = prompt.ask_select(&BASE_FIELD, 0).await?;
88    let base = BaseImage::ALL[base_idx];
89
90    let toolchain_indices = prompt.ask_multiselect(&TOOLCHAIN_FIELD, &[]).await?;
91    let toolchains: Vec<Toolchain> = toolchain_indices
92        .iter()
93        .map(|&i| Toolchain::ALL[i])
94        .collect();
95
96    // Defaults to `fs` only -- the most-defensible v0 minimum.
97    let default_mcps: Vec<usize> = vec![DEFAULT_MCP_INDEX];
98    let mcp_indices = prompt.ask_multiselect(&MCP_FIELD, &default_mcps).await?;
99    let mcps: Vec<McpServer> = mcp_indices.iter().map(|&i| McpServer::ALL[i]).collect();
100
101    let dockerfile = render::render(base, &toolchains, &mcps);
102    write_atomic(&dockerfile_path, &dockerfile)?;
103    eprintln!(
104        "[outrig] wrote {}",
105        display_rel(&dockerfile_path, repo_root)
106    );
107
108    insert_image_block(&mut doc, &name, &mcps);
109    write_atomic(&cfg_path, &doc.to_string())?;
110    eprintln!(
111        "[outrig] added [images.{name}] block to {}",
112        display_rel(&cfg_path, repo_root)
113    );
114    if mcps.is_empty() {
115        eprintln!("[outrig] [images.{name}.mcp] is empty");
116    } else {
117        let names: Vec<&str> = mcps.iter().map(|m| m.as_str()).collect();
118        eprintln!(
119            "[outrig] added [images.{name}.mcp] entries: {}",
120            names.join(", ")
121        );
122    }
123
124    Ok(())
125}
126
127// ---- prompt fields --------------------------------------------------------
128
129pub(crate) const NAME_FIELD: Field = Field {
130    name: "Image name",
131    description: "Used as the [images.<name>] key. Must match `^[a-zA-Z][a-zA-Z0-9_-]*$`.",
132    options: &[],
133    doc_link: "doc/usage/image.md",
134};
135
136const BASES: &[(&str, &str)] = &[
137    (
138        BaseImage::DebianBookwormSlim.as_str(),
139        BaseImage::DebianBookwormSlim.description(),
140    ),
141    (
142        BaseImage::Ubuntu24_04.as_str(),
143        BaseImage::Ubuntu24_04.description(),
144    ),
145    (
146        BaseImage::AlpineLatest.as_str(),
147        BaseImage::AlpineLatest.description(),
148    ),
149    (
150        BaseImage::Node20BookwormSlim.as_str(),
151        BaseImage::Node20BookwormSlim.description(),
152    ),
153    (
154        BaseImage::Python3_12Slim.as_str(),
155        BaseImage::Python3_12Slim.description(),
156    ),
157];
158
159const BASE_FIELD: Field = Field {
160    name: "Base image",
161    description: "The Dockerfile's `FROM` line. Pick one of the curated starting points.",
162    options: BASES,
163    doc_link: "doc/usage/image.md",
164};
165
166const TOOLCHAINS: &[(&str, &str)] = &[
167    (
168        "rust",
169        "rustup + stable toolchain (cargo, rustfmt, clippy).",
170    ),
171    ("node", "Node 20 LTS via the base image's package manager."),
172    ("python", "CPython 3 with pip and venv."),
173    ("go", "Go 1.22."),
174    ("none", "Just the base image -- nothing extra installed."),
175];
176
177const TOOLCHAIN_FIELD: Field = Field {
178    name: "Language toolchains",
179    description: "Pick zero or more language toolchains to install in the image. \
180                  The Dockerfile template adds the corresponding install steps; \
181                  you can edit the file afterwards.",
182    options: TOOLCHAINS,
183    doc_link: "doc/usage/image.md",
184};
185
186const MCPS: &[(&str, &str)] = &[
187    (McpServer::Fs.as_str(), McpServer::Fs.description()),
188    (McpServer::Git.as_str(), McpServer::Git.description()),
189];
190
191const MCP_FIELD: Field = Field {
192    name: "MCP servers",
193    description: "Pick zero or more MCP servers to install in the image. The \
194                  Dockerfile installs each server's package and the matching \
195                  [images.<name>.mcp] entry is appended to config.toml.",
196    options: MCPS,
197    doc_link: "doc/concepts/mcp-servers.md",
198};
199
200/// Slice of every `Field` declared in this module, for `prompt_doc_sync.rs`.
201pub const DOC_SYNC_FIELDS: &[&Field] = &[&NAME_FIELD, &BASE_FIELD, &TOOLCHAIN_FIELD, &MCP_FIELD];
202
203/// Index into `McpServer::ALL` of the default selection (the `fs`
204/// filesystem server). A `const` rather than a runtime `position()` lookup
205/// since `ALL`'s order is itself a deliberate stable contract.
206const DEFAULT_MCP_INDEX: usize = 0;
207const _: () = assert!(matches!(McpServer::ALL[DEFAULT_MCP_INDEX], McpServer::Fs));
208
209// ---- helpers --------------------------------------------------------------
210
211fn display_rel<'a>(path: &'a Path, root: &Path) -> std::path::Display<'a> {
212    path.strip_prefix(root).unwrap_or(path).display()
213}
214
215fn load_doc(cfg_path: &Path) -> Result<DocumentMut> {
216    let text = match std::fs::read_to_string(cfg_path) {
217        Ok(t) => t,
218        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
219        Err(e) => return Err(e.into()),
220    };
221    text.parse::<DocumentMut>().map_err(|e| {
222        OutrigError::Configuration(format!("parsing {}: {e}", cfg_path.display())).into()
223    })
224}
225
226fn image_block_exists(doc: &DocumentMut, name: &str) -> bool {
227    doc.get("images")
228        .and_then(|c| c.as_table_like())
229        .is_some_and(|t| t.contains_key(name))
230}
231
232fn insert_image_block(doc: &mut DocumentMut, name: &str, mcps: &[McpServer]) {
233    let rel = image_dir_rel(name);
234    let dockerfile = rel.join("Dockerfile").to_string_lossy().into_owned();
235    let context = rel.to_string_lossy().into_owned();
236
237    let mut entry = Table::new();
238    entry.insert("dockerfile", Item::Value(Value::from(dockerfile)));
239    entry.insert("context", Item::Value(Value::from(context)));
240
241    let mut mcp = Table::new();
242    for server in mcps {
243        mcp.insert(server.as_str(), mcp_value(*server));
244    }
245    entry.insert("mcp", Item::Table(mcp));
246
247    let images = doc
248        .entry("images")
249        .or_insert_with(|| {
250            let mut t = Table::new();
251            t.set_implicit(true);
252            Item::Table(t)
253        })
254        .as_table_mut()
255        .expect("images must be a table");
256    images.insert(name, Item::Table(entry));
257}
258
259fn mcp_value(server: McpServer) -> Item {
260    let mut cmd = Array::new();
261    for arg in server.command_args() {
262        cmd.push(*arg);
263    }
264    let mut full = InlineTable::new();
265    full.insert("command", Value::Array(cmd));
266    Item::Value(Value::InlineTable(full))
267}