Skip to main content

outrig_cli/init/
repo.rs

1//! Repo-config phase of `outrig init`, plus the bootstrap fallback used
2//! by `outrig image add` when run in an uninitialized repo.
3//!
4//! Two public entry points share one private writer:
5//! - [`ensure`] is what `outrig init` calls: idempotent: write the config
6//!   if missing, log + skip if present.
7//! - [`resolve_or_bootstrap`] is what `outrig image add` calls before
8//!   dispatching: walk up to find an existing `.agents/outrig/config.toml`,
9//!   and on `NoRepoConfig` prompt the user to bootstrap one against `cwd`.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use heck::ToKebabCase;
15
16use crate::config_init;
17use crate::error::{OutrigError, Result};
18use crate::hf::HfTreeFetcher;
19use crate::image_setup::add as image_add;
20use crate::init::prompt::{Field, PromptSource};
21use crate::paths::{find_repo_root_from, repo_config_path, write_atomic};
22use outrig::config::{Agent, Config, LlmProvider, Model, Workspace};
23
24/// Idempotent. Returns `Some(image_name)` when this call wrote the
25/// repo config (the user named an image during the bootstrap), or
26/// `None` when the file already existed and was left alone. Callers
27/// thread the name into the subsequent `image_setup::add::run_with` so the
28/// image-name prompt doesn't fire twice.
29pub async fn ensure(
30    repo_root: &Path,
31    global_path: &Path,
32    prompt: &mut impl PromptSource,
33    hf: &mut impl HfTreeFetcher,
34) -> Result<Option<String>> {
35    let cfg_path = repo_config_path(repo_root);
36    if cfg_path.exists() {
37        eprintln!(
38            "[outrig] using existing repo config at {}",
39            cfg_path.display()
40        );
41        return Ok(None);
42    }
43    eprintln!(
44        "[outrig] no repo config at {} -- let's create one.",
45        cfg_path.display()
46    );
47    let name = write_repo_config(repo_root, global_path, prompt, hf).await?;
48    Ok(Some(name))
49}
50
51/// Resolve the repo root for `outrig image add`. Walks up via
52/// [`find_repo_root_from`]; on [`OutrigError::NoRepoConfig`] prompts
53/// the user, and on yes bootstraps the repo config against `cwd` and
54/// returns `cwd`. On no, re-raises `NoRepoConfig` so the exit code and
55/// error string match the previous behavior for scripts that test the
56/// unconfigured case.
57/// Returns the resolved repo root paired with `Some(image_name)` when
58/// this call ran the bootstrap (the user named an image) or `None`
59/// when an existing config was found by walking up.
60pub async fn resolve_or_bootstrap(
61    cwd: &Path,
62    global_path: &Path,
63    prompt: &mut impl PromptSource,
64    hf: &mut impl HfTreeFetcher,
65) -> Result<(PathBuf, Option<String>)> {
66    match find_repo_root_from(cwd) {
67        Ok(root) => Ok((root, None)),
68        Err(OutrigError::NoRepoConfig) => {
69            eprintln!(
70                "[outrig] no .agents/outrig/config.toml found in {} or any parent.",
71                cwd.display()
72            );
73            if !prompt.ask_bool(&CONFIGURE_NOW_FIELD, true).await? {
74                eprintln!("[outrig] skipping; run `outrig init` later to set up.");
75                return Err(OutrigError::NoRepoConfig.into());
76            }
77            let name = write_repo_config(cwd, global_path, prompt, hf).await?;
78            Ok((cwd.to_path_buf(), Some(name)))
79        }
80        Err(other) => Err(other.into()),
81    }
82}
83
84/// Walks the three repo-config sections (image / model / agent),
85/// builds a [`Config`], serializes to TOML, and writes atomically via
86/// [`write_atomic`]. Section headers signal each transition so
87/// the prompts don't bleed together.
88async fn write_repo_config(
89    repo_root: &Path,
90    global_path: &Path,
91    prompt: &mut impl PromptSource,
92    hf: &mut impl HfTreeFetcher,
93) -> Result<String> {
94    eprintln!();
95    eprintln!("Configuring models");
96    let global = load_global_summary(global_path)?;
97    let model_choices = ask_repo_models(prompt, &global, hf).await?;
98
99    // Agent before image: the image section then flows directly
100    // into `image_setup::add`'s base/toolchains/MCP prompts without an
101    // agent-section interruption.
102    eprintln!();
103    eprintln!("Configuring your first agent");
104    let agent_name = prompt
105        .ask_string(&AGENT_NAME_FIELD, DEFAULT_AGENT_NAME)
106        .await?;
107    // If neither the global nor the repo sets a default-model, the agent
108    // *must* pin its own to produce a config that validates. Otherwise
109    // the agent inherits whichever default is set.
110    let agent_model = ask_agent_model(prompt, &global, &model_choices).await?;
111    let preamble = prompt
112        .ask_string(&PREAMBLE_FIELD, "You are a careful coding assistant.")
113        .await?;
114
115    eprintln!();
116    eprintln!("Configuring your first image");
117    let default_name = default_image_name(repo_root);
118    let image_name = prompt
119        .ask_string(&image_add::NAME_FIELD, &default_name)
120        .await?;
121    let ws_default = Workspace::default();
122    let host_path = prompt
123        .ask_string(&HOST_PATH_FIELD, &ws_default.host_path.to_string_lossy())
124        .await?;
125    let container_path = prompt
126        .ask_string(
127            &CONTAINER_PATH_FIELD,
128            &ws_default.container_path.to_string_lossy(),
129        )
130        .await?;
131
132    let toml_text = render(
133        agent_name,
134        agent_model,
135        image_name.clone(),
136        host_path,
137        container_path,
138        model_choices,
139        preamble,
140    )?;
141    let cfg_path = repo_config_path(repo_root);
142    write_atomic(&cfg_path, &toml_text)?;
143    eprintln!();
144    eprintln!("[outrig] wrote {}", cfg_path.display());
145    Ok(image_name)
146}
147
148/// Snapshot of the parts of the global config we surface in the model
149/// section. Providers come along so the repo-model loop can validate
150/// `provider = "<name>"` references against what's actually defined.
151#[derive(Default)]
152struct GlobalSummary {
153    providers: BTreeMap<String, LlmProvider>,
154    models: Vec<String>,
155    default_model: Option<String>,
156}
157
158/// Best-effort load of the global config. A missing file yields an empty
159/// summary (the user will be prompted to run `outrig config init`); parse
160/// errors propagate so a corrupt config surfaces immediately.
161fn load_global_summary(global_path: &Path) -> Result<GlobalSummary> {
162    let text = match std::fs::read_to_string(global_path) {
163        Ok(t) => t,
164        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
165            return Ok(GlobalSummary::default());
166        }
167        Err(e) => return Err(e.into()),
168    };
169    let cfg = Config::load_from_str(&text)?;
170    Ok(GlobalSummary {
171        providers: cfg.providers,
172        models: cfg.models.keys().cloned().collect(),
173        default_model: cfg.default_model,
174    })
175}
176
177/// Surface what the global config offers, then on yes walk the repo-model
178/// definition loop (reusing `config::init`'s prompt sequence) plus the
179/// default-model selection. Returns the new repo-local models and the
180/// repo-level `default-model` to embed in the rendered config; an empty
181/// map / `None` means "inherit everything from global".
182async fn ask_repo_models(
183    prompt: &mut impl PromptSource,
184    summary: &GlobalSummary,
185    hf: &mut impl HfTreeFetcher,
186) -> Result<RepoModelChoices> {
187    if summary.providers.is_empty() {
188        eprintln!(
189            "[outrig] no providers defined in your global config -- run \
190             `outrig config init` first; without a provider this agent \
191             can't reach an LLM."
192        );
193        return Ok(RepoModelChoices::default());
194    }
195
196    let listing = if summary.models.is_empty() {
197        "(none)".to_string()
198    } else {
199        summary.models.join(", ")
200    };
201    match &summary.default_model {
202        Some(d) => {
203            eprintln!("[outrig] models available in your global config: {listing} (default: {d})")
204        }
205        None => eprintln!("[outrig] models available in your global config: {listing}"),
206    }
207
208    if prompt.ask_bool(&CONFIGURE_REPO_MODELS_FIELD, true).await? {
209        let (models, new_providers) =
210            config_init::prompt_models_loop(prompt, &summary.providers, hf).await?;
211        let default = config_init::prompt_default_model(prompt, &models).await?;
212        return Ok(RepoModelChoices {
213            models,
214            providers: new_providers,
215            default_model: default,
216        });
217    }
218
219    // User declined to define repo-specific models. Offer to pin one of
220    // the global models as the repo's default-model anyway -- skipping
221    // the prompt would leave a config with no `default-model` when the
222    // global also has none, which fails validation.
223    let default = ask_repo_default_model_from_global(prompt, summary).await?;
224    Ok(RepoModelChoices {
225        models: BTreeMap::new(),
226        providers: BTreeMap::new(),
227        default_model: default,
228    })
229}
230
231/// Offered after the user declined to define repo-specific models. The
232/// gate's default flips with whether a global default already exists --
233/// if it does, inheriting is the natural choice (default N); if not,
234/// picking is needed to avoid a broken config (default Y).
235async fn ask_repo_default_model_from_global(
236    prompt: &mut impl PromptSource,
237    summary: &GlobalSummary,
238) -> Result<Option<String>> {
239    if summary.models.is_empty() {
240        return Ok(None);
241    }
242    let prompt_default = summary.default_model.is_none();
243    if !prompt
244        .ask_bool(&REPO_DEFAULT_MODEL_FIELD, prompt_default)
245        .await?
246    {
247        return Ok(None);
248    }
249    Ok(Some(pick_global_model(prompt, summary).await?))
250}
251
252/// Forces the agent to pin its own `model` when there's no default-model
253/// set anywhere -- otherwise the merged config fails the
254/// `agent omits 'model' and no top-level 'default-model' is set`
255/// validation rule. When a default is set somewhere, returns `None` so
256/// the agent inherits.
257async fn ask_agent_model(
258    prompt: &mut impl PromptSource,
259    summary: &GlobalSummary,
260    repo: &RepoModelChoices,
261) -> Result<Option<String>> {
262    if repo.default_model.is_some() || summary.default_model.is_some() {
263        return Ok(None);
264    }
265    // Models the agent could reference: repo-defined first, then global.
266    let mut available: Vec<&str> = repo.models.keys().map(String::as_str).collect();
267    available.extend(summary.models.iter().map(String::as_str));
268    if available.is_empty() {
269        eprintln!(
270            "[outrig] no models defined anywhere -- the agent will be \
271             written without a model and the config won't run until \
272             you add one."
273        );
274        return Ok(None);
275    }
276    eprintln!(
277        "[outrig] no default-model is set globally or in this repo; \
278         this agent needs an explicit model."
279    );
280    eprintln!("[outrig] models available: {}", available.join(", "));
281    let suggestion = available[0].to_string();
282    loop {
283        let answer = prompt.ask_string(&AGENT_MODEL_FIELD, &suggestion).await?;
284        if available.iter().any(|m| *m == answer) {
285            return Ok(Some(answer));
286        }
287        eprintln!(
288            "[outrig] no model named `{answer}`; available: {}",
289            available.join(", ")
290        );
291    }
292}
293
294/// Prompts for a global model name, validating against `summary.models`.
295/// Used by both the post-N "set repo default-model?" path and any other
296/// caller that needs the user to pick from already-configured models.
297async fn pick_global_model(
298    prompt: &mut impl PromptSource,
299    summary: &GlobalSummary,
300) -> Result<String> {
301    let listing = summary.models.join(", ");
302    let suggestion = summary
303        .default_model
304        .as_deref()
305        .unwrap_or(&summary.models[0])
306        .to_string();
307    loop {
308        let answer = prompt.ask_string(&PICK_MODEL_FIELD, &suggestion).await?;
309        if summary.models.iter().any(|m| m == &answer) {
310            return Ok(answer);
311        }
312        eprintln!("[outrig] no model named `{answer}`; available: {listing}");
313    }
314}
315
316/// Outcome of the model section: any new repo-local providers / models
317/// the user defined, plus the chosen repo `default-model` if any.
318#[derive(Default)]
319struct RepoModelChoices {
320    providers: BTreeMap<String, LlmProvider>,
321    models: BTreeMap<String, Model>,
322    default_model: Option<String>,
323}
324
325/// Default agent name used in the bootstrap prompt. A simple constant --
326/// agents are named by role, not by repo, so the same default works
327/// regardless of where you run from.
328pub(crate) const DEFAULT_AGENT_NAME: &str = "coder";
329
330/// Suggest `<repo-folder-kebab>-standard` as the default image-config
331/// name, so the image (and `default-image`) carries the repo's
332/// identity by default. Falls back to plain `"standard"` when the path
333/// has no usable last component. Shared with `image_setup::add` so its
334/// name prompt suggests the same value as `default-image` written
335/// here.
336pub(crate) fn default_image_name(repo_root: &Path) -> String {
337    let folder = repo_root
338        .file_name()
339        .and_then(|s| s.to_str())
340        .map(str::trim)
341        .filter(|s| !s.is_empty())
342        .map(|s| s.to_kebab_case())
343        .filter(|s| !s.is_empty());
344    match folder {
345        Some(name) => format!("{name}-standard"),
346        None => "standard".to_string(),
347    }
348}
349
350fn render(
351    agent_name: String,
352    agent_model: Option<String>,
353    image_name: String,
354    host_path: String,
355    container_path: String,
356    model_choices: RepoModelChoices,
357    preamble: String,
358) -> Result<String> {
359    let mut agents = BTreeMap::new();
360    agents.insert(
361        agent_name.clone(),
362        Agent {
363            model: agent_model,
364            image: None,
365            preamble: Some(preamble),
366            temperature: None,
367            max_tokens: None,
368            tool_call_max: None,
369            tool_result_max: None,
370        },
371    );
372    let cfg = Config {
373        default_image: Some(image_name),
374        default_agent: Some(agent_name),
375        default_model: model_choices.default_model,
376        tool_call_max: None,
377        tool_result_max: None,
378        workspace: Workspace {
379            host_path: PathBuf::from(host_path),
380            container_path: PathBuf::from(container_path),
381            mounts: Vec::new(),
382        },
383        providers: model_choices.providers,
384        models: model_choices.models,
385        agents,
386        ..Config::default()
387    };
388    toml::to_string_pretty(&cfg)
389        .map_err(|e| OutrigError::Configuration(format!("rendering repo config: {e}")).into())
390}
391
392// ---- prompt fields --------------------------------------------------------
393
394const CONFIGURE_NOW_FIELD: Field = Field {
395    name: "Configure outrig in this directory now?",
396    description: "Yes walks the same prompts as `outrig init` (workspace, model, \
397                  agent) and writes .agents/outrig/config.toml here, then \
398                  continues with `image add`. No exits without changes.",
399    options: &[],
400    doc_link: "doc/usage/init.md",
401};
402
403const HOST_PATH_FIELD: Field = Field {
404    name: "Workspace host-path",
405    description: "Path on the host that gets bind-mounted into the container. \
406                  Resolved relative to the repo root.",
407    options: &[],
408    doc_link: "doc/concepts/workspace.md",
409};
410
411const CONTAINER_PATH_FIELD: Field = Field {
412    name: "Workspace container-path",
413    description: "Path inside the container where the host workspace is mounted.",
414    options: &[],
415    doc_link: "doc/concepts/workspace.md",
416};
417
418const AGENT_NAME_FIELD: Field = Field {
419    name: "Agent name",
420    description: "Names the agent you're creating now. Becomes the \
421                  [agents.<name>] key and is also set as default-agent.",
422    options: &[],
423    doc_link: "doc/reference/config.md",
424};
425
426const CONFIGURE_REPO_MODELS_FIELD: Field = Field {
427    name: "Would you like to configure LLM models specific to this repo?",
428    description: "Yes: define one or more [models.<name>] entries in the repo \
429                  config (using the global providers) and optionally set one \
430                  as the repo default-model. No: inherit the global models \
431                  and default-model.",
432    options: &[],
433    doc_link: "doc/concepts/llm-providers.md",
434};
435
436const REPO_DEFAULT_MODEL_FIELD: Field = Field {
437    name: "Set a default-model for this repo?",
438    description: "Yes: pin one of the global models as the repo's \
439                  `default-model`. No: inherit the global default-model \
440                  if one is set, or fall back to per-agent model selection.",
441    options: &[],
442    doc_link: "doc/reference/config.md",
443};
444
445const PICK_MODEL_FIELD: Field = Field {
446    name: "Default model",
447    description: "Pick one of the models from your global config to set as \
448                  the repo-level default-model.",
449    options: &[],
450    doc_link: "doc/reference/config.md",
451};
452
453const AGENT_MODEL_FIELD: Field = Field {
454    name: "Model for this agent",
455    description: "No default-model is configured globally or at the repo \
456                  level, so this agent needs an explicit `model`. Pick one \
457                  of the models available in scope.",
458    options: &[],
459    doc_link: "doc/reference/config.md",
460};
461
462const PREAMBLE_FIELD: Field = Field {
463    name: "Preamble (one line, edit later)",
464    description: "System prompt prepended to every conversation with this agent.",
465    options: &[],
466    doc_link: "doc/reference/config.md",
467};
468
469/// Slice of every `Field` declared in this module, for `prompt_doc_sync.rs`.
470pub const DOC_SYNC_FIELDS: &[&Field] = &[
471    &CONFIGURE_NOW_FIELD,
472    &HOST_PATH_FIELD,
473    &CONTAINER_PATH_FIELD,
474    &AGENT_NAME_FIELD,
475    &CONFIGURE_REPO_MODELS_FIELD,
476    &REPO_DEFAULT_MODEL_FIELD,
477    &PICK_MODEL_FIELD,
478    &AGENT_MODEL_FIELD,
479    &PREAMBLE_FIELD,
480];