1use 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
24pub 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
51pub 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
84async 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 eprintln!();
103 eprintln!("Configuring your first agent");
104 let agent_name = prompt
105 .ask_string(&AGENT_NAME_FIELD, DEFAULT_AGENT_NAME)
106 .await?;
107 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#[derive(Default)]
152struct GlobalSummary {
153 providers: BTreeMap<String, LlmProvider>,
154 models: Vec<String>,
155 default_model: Option<String>,
156}
157
158fn 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
177async 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 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
231async 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
252async 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 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
294async 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#[derive(Default)]
319struct RepoModelChoices {
320 providers: BTreeMap<String, LlmProvider>,
321 models: BTreeMap<String, Model>,
322 default_model: Option<String>,
323}
324
325pub(crate) const DEFAULT_AGENT_NAME: &str = "coder";
329
330pub(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
392const 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
469pub 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];