1use std::{
2 io::{self, IsTerminal},
3 num::NonZeroUsize,
4 sync::Arc,
5 time::Duration,
6};
7
8use {
9 crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
10 crate::credential_store::AppCredentialStore,
11 crate::diagnostics::RuntimeDiagnostics,
12 crate::herdr::HerdrReporter,
13 crate::update,
14 rho_providers::model::ModelError,
15};
16
17use super::{
18 agent_binding::{AgentBinder, AgentInvocation, AgentRole},
19 automation, automation_protocol, cli_config,
20 config_repository::ConfigRepository,
21 interactive, login,
22 sdk_config::SdkBootstrapOptions,
23 sessions_cli,
24};
25
26pub async fn run(cli: Cli) -> anyhow::Result<()> {
27 let run_output = match &cli.command {
28 Some(Command::Run { output, .. }) => Some(*output),
29 _ => None,
30 };
31 let result = run_inner(cli).await;
32 let Err(error) = result else {
33 return Ok(());
34 };
35 if error.downcast_ref::<automation::AutomationExit>().is_some()
36 || error
37 .downcast_ref::<automation::AutomationInterrupted>()
38 .is_some()
39 {
40 return Err(error);
41 }
42 if run_output == Some(OutputFormat::Jsonl) {
43 automation::emit_startup_failure()?;
44 return Err(automation::AutomationExit::new(
45 2,
46 automation_protocol::TerminalReason::ConfigurationError,
47 "configuration failed",
48 )
49 .into());
50 }
51 if run_output.is_some() {
52 return Err(automation::AutomationExit::new(
53 2,
54 automation_protocol::TerminalReason::ConfigurationError,
55 error.to_string(),
56 )
57 .into());
58 }
59 Err(error)
60}
61
62async fn run_inner(cli: Cli) -> anyhow::Result<()> {
63 cli_config::validate(&cli)?;
64 if let EarlyDispatch::Handled(result) = dispatch_early_command(&cli).await? {
65 return result;
66 }
67
68 let PreparedStartup {
69 cli,
70 mut config,
71 config_repository,
72 cwd,
73 automation_prompt,
74 output_file,
75 output,
76 max_steps,
77 timeout,
78 bound_agent,
79 bound_reasoning_source,
80 provider_refresh,
81 store,
82 } = prepare_startup(cli).await?;
83
84 validate_terminal_mode(&cli)?;
85 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
86 cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
87 let herdr = HerdrReporter::from_env();
88 if let Some(prompt) = automation_prompt {
89 return run_automation_startup(AutomationStartup {
90 prompt,
91 config: &config,
92 config_repository: &config_repository,
93 cwd,
94 cli: &cli,
95 bound_agent,
96 output_file,
97 output,
98 max_steps,
99 timeout,
100 herdr,
101 })
102 .await;
103 }
104 run_interactive_startup(InteractiveStartup {
105 cli: &cli,
106 config,
107 config_repository,
108 cwd,
109 bound_agent,
110 bound_reasoning_source,
111 herdr,
112 })
113 .await
114}
115
116enum EarlyDispatch {
117 Handled(anyhow::Result<()>),
118 Continue,
119}
120
121async fn dispatch_early_command(cli: &Cli) -> anyhow::Result<EarlyDispatch> {
122 if let Some(Command::CredentialStore { command }) = &cli.command {
123 return Ok(EarlyDispatch::Handled(run_credential_store_command(
124 command,
125 cli.config.clone(),
126 )));
127 }
128 if let Some(Command::Sessions { command }) = &cli.command {
129 return Ok(EarlyDispatch::Handled(sessions_cli::run(command)));
130 }
131 if let Some(Command::Attach { id }) = &cli.command {
132 return Ok(EarlyDispatch::Handled(
133 crate::tui::run_attachment(id, HerdrReporter::from_env()).await,
134 ));
135 }
136 if matches!(cli.command, Some(Command::Update)) {
137 return Ok(EarlyDispatch::Handled(
138 update::run_update(env!("CARGO_PKG_VERSION")).await,
139 ));
140 }
141 if let Some(Command::Login {
142 provider,
143 device_auth,
144 }) = &cli.command
145 {
146 let config_repository = ConfigRepository::new(cli.config.clone());
147 let mut config = config_repository.load()?;
148 let config_path = absolute_config_path(&config_repository)?;
149 ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
150 crate::credential_store::initialize_from_config(&mut config, &config_path)?;
151 return Ok(EarlyDispatch::Handled(
152 login::run(provider, *device_auth).await,
153 ));
154 }
155 Ok(EarlyDispatch::Continue)
156}
157
158struct PreparedStartup {
159 cli: Cli,
160 config: crate::config::Config,
161 config_repository: ConfigRepository,
162 cwd: std::path::PathBuf,
163 automation_prompt: Option<String>,
164 output_file: Option<std::path::PathBuf>,
165 output: OutputFormat,
166 max_steps: Option<NonZeroUsize>,
167 timeout: Option<Duration>,
168 bound_agent: super::agent_binding::BoundAgent,
169 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
170 provider_refresh: cli_config::ProviderRefreshStatus,
171 store: AppCredentialStore,
172}
173
174async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
175 let config_path = cli.config.clone();
176 let config_repository = ConfigRepository::new(config_path.clone());
177 let mut config = config_repository.load()?;
178 let absolute_config = absolute_config_path(&config_repository)?;
179 crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
180 let cwd = std::env::current_dir()?;
181 let automation_prompt = automation::prompt_for_command(&cli.command)?;
182 let (output_file, output, max_steps, timeout) = match &cli.command {
183 Some(Command::Run {
184 output_file,
185 output,
186 max_steps,
187 timeout,
188 ..
189 }) => (output_file.clone(), *output, *max_steps, *timeout),
190 _ => (None, OutputFormat::Text, None, None),
191 };
192 let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
193 let selected_agent = cli.agent.as_deref().unwrap_or("default");
194 let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
195
196 let store = AppCredentialStore;
197 let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
198 let mut save_config = cli_config::apply_overrides(&mut config, &cli)?;
199 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
200 save_config |= cli_config::normalize_reasoning_for_cli(
201 &mut config,
202 if cli.reasoning.is_some() {
203 rho_providers::model::ReasoningRequestSource::Explicit
204 } else {
205 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
206 },
207 )?;
208 if save_config {
209 config_repository.save(&config)?;
210 }
211 let reasoning_before_binding = config.reasoning;
212 let role = if automation_prompt.is_some() {
213 AgentRole::AutomationRoot
214 } else {
215 AgentRole::InteractiveRoot
216 };
217 let bound_agent = AgentBinder::bind(
218 definition,
219 AgentInvocation {
220 role,
221 available_tools: host_capabilities(&cli, &config, role),
222 },
223 &config,
224 )?;
225 config = bound_agent.rho_config().cloned().unwrap_or(config);
226 let bound_reasoning_source =
227 if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
228 rho_providers::model::ReasoningRequestSource::Explicit
229 } else {
230 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
231 };
232
233 Ok(PreparedStartup {
234 cli,
235 config,
236 config_repository,
237 cwd,
238 automation_prompt,
239 output_file,
240 output,
241 max_steps,
242 timeout,
243 bound_agent,
244 bound_reasoning_source,
245 provider_refresh,
246 store,
247 })
248}
249
250struct AutomationStartup<'a> {
251 prompt: String,
252 config: &'a crate::config::Config,
253 config_repository: &'a ConfigRepository,
254 cwd: std::path::PathBuf,
255 cli: &'a Cli,
256 bound_agent: super::agent_binding::BoundAgent,
257 output_file: Option<std::path::PathBuf>,
258 output: OutputFormat,
259 max_steps: Option<NonZeroUsize>,
260 timeout: Option<Duration>,
261 herdr: HerdrReporter,
262}
263
264async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
265 let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
266 automation::run(
267 startup.prompt,
268 automation::Startup {
269 config: startup.config,
270 config_path: absolute_config_path(startup.config_repository)?,
271 cwd: startup.cwd,
272 no_system_prompt: startup.cli.no_system_prompt,
273 no_tools: startup.cli.no_tools,
274 no_subagents: startup.cli.no_subagents,
275 usage_purpose: "agent",
276 parent_session_id: None,
277 agent: startup.bound_agent,
278 output_file: startup.output_file,
279 output: startup.output,
280 max_steps: startup.max_steps,
281 timeout: startup.timeout,
282 diagnostics,
283 herdr: startup.herdr,
284 host_input: None,
285 },
286 )
287 .await
288}
289
290struct InteractiveStartup<'a> {
291 cli: &'a Cli,
292 config: crate::config::Config,
293 config_repository: ConfigRepository,
294 cwd: std::path::PathBuf,
295 bound_agent: super::agent_binding::BoundAgent,
296 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
297 herdr: HerdrReporter,
298}
299
300async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
301 let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
302
303 let pending_update_notice = startup
304 .config
305 .check_for_updates
306 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
307
308 let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
309 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
310 Arc::new(AppCredentialStore),
311 );
312 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
313 sdk_options.provider,
314 &credentials,
315 );
316 let (missing_auth_error, missing_auth_model_error) = match provider_result {
317 Ok(_) => (None, None),
318 Err(error) if is_interactive_startup_unavailable_error(&error) => {
319 (Some(error.to_string()), Some(error))
320 }
321 Err(error) => return Err(error.into()),
322 };
323 interactive::run(interactive::Startup {
324 cli: startup.cli,
325 config: startup.config,
326 config_path: absolute_config_path(&startup.config_repository)?,
327 config_repository: startup.config_repository,
328 cwd: startup.cwd,
329 missing_auth_error,
330 missing_auth_model_error,
331 pending_update_notice,
332 diagnostics,
333 herdr: startup.herdr,
334 agent: startup.bound_agent,
335 reasoning_source: startup.bound_reasoning_source,
336 })
337 .await
338}
339
340fn bind_agent_diagnostics(
341 config: &crate::config::Config,
342 agent: &super::agent_binding::BoundAgent,
343) -> RuntimeDiagnostics {
344 let diagnostics = RuntimeDiagnostics::new(config);
345 diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
346 diagnostics
347}
348
349fn ensure_cli_credential_store_choice(
350 config: &mut crate::config::Config,
351 config_path: Option<std::path::PathBuf>,
352) -> anyhow::Result<()> {
353 use rho_providers::credentials::CredentialStoreBackend;
354 use std::io::{self, IsTerminal, Write};
355
356 let Some(request) = crate::credential_store::choice_request(config) else {
357 return Ok(());
358 };
359
360 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
361 anyhow::bail!(
362 "credential store is unset; set it before non-interactive login with \
363`rho credential-store set os|file`, behavior.credential_store in config.toml, \
364or RHO_CREDENTIAL_STORE=os|file"
365 );
366 }
367
368 let backends = request.available_backends();
369 if backends.is_empty() {
370 anyhow::bail!(
371 "no credential store backend is available (os: {}; file: {})",
372 request.os.detail,
373 request.file.detail
374 );
375 }
376
377 eprintln!("Choose where Rho stores provider credentials:");
378 eprintln!("This is saved to config and used for future logins on this machine.");
379 if request.os.available {
380 eprintln!(" [1] OS credential store (recommended)");
381 } else {
382 eprintln!(
383 " [1] OS credential store (unavailable: {})",
384 request.os.detail
385 );
386 }
387 if request.file.available {
388 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
389 } else {
390 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
391 }
392 let default_backend = request
393 .default_backend()
394 .unwrap_or(CredentialStoreBackend::Os);
395 let default_hint = match default_backend {
396 CredentialStoreBackend::Os => "1",
397 CredentialStoreBackend::File => "2",
398 };
399 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
400 io::stderr().flush()?;
401
402 let mut answer = String::new();
403 io::stdin().read_line(&mut answer)?;
404 let backend = match answer.trim() {
405 "" => default_backend,
406 "1" | "os" | "OS" => CredentialStoreBackend::Os,
407 "2" | "file" | "FILE" => CredentialStoreBackend::File,
408 other => {
409 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
410 }
411 };
412 if !backends.contains(&backend) {
413 let detail = request.detail_for(backend);
414 anyhow::bail!(
415 "{} credential store is unavailable: {detail}",
416 backend.as_str()
417 );
418 }
419
420 let path = crate::credential_store::set_backend(backend, config_path)?;
421 config.credential_store = Some(backend);
422 eprintln!(
423 "credential store set to {} in {}",
424 backend.as_str(),
425 path.display()
426 );
427 Ok(())
428}
429
430fn run_credential_store_command(
431 command: &CredentialStoreCommand,
432 config_path: Option<std::path::PathBuf>,
433) -> anyhow::Result<()> {
434 match command {
435 CredentialStoreCommand::Probe { backend } => {
436 let result = crate::credential_store::probe(*backend);
437 if result.available {
438 println!("available: {}", result.detail);
439 Ok(())
440 } else {
441 anyhow::bail!(result.detail)
442 }
443 }
444 CredentialStoreCommand::Status => {
445 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
447 None => println!("unset"),
448 Some(backend) => println!("{}", backend.as_str()),
449 }
450 Ok(())
451 }
452 CredentialStoreCommand::Set { backend } => {
453 let path = crate::credential_store::set_backend(*backend, config_path)?;
454 println!(
455 "credential store set to {} in {}",
456 backend.as_str(),
457 path.display()
458 );
459 Ok(())
460 }
461 }
462}
463
464fn host_capabilities(
465 cli: &Cli,
466 config: &crate::config::Config,
467 role: AgentRole,
468) -> crate::agent::AgentCapabilities {
469 use crate::agent::ToolCapability;
470
471 if cli.no_tools {
472 return crate::agent::AgentCapabilities::default();
473 }
474 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
475 if !crate::tools::web::access_tools(config).is_available() {
476 tools.remove(&ToolCapability::WebSearch);
477 }
478 #[cfg(windows)]
479 tools.remove(&ToolCapability::Bash);
480 #[cfg(not(windows))]
481 tools.remove(&ToolCapability::Powershell);
482 if cli.no_subagents || !config.enable_subagents {
483 tools.remove(&ToolCapability::Agent);
484 tools.remove(&ToolCapability::Agents);
485 }
486 if role != AgentRole::InteractiveRoot {
487 tools.remove(&ToolCapability::Questionnaire);
488 }
489 #[cfg(debug_assertions)]
490 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
491 tools.insert(ToolCapability::Extension(
492 crate::tools::tui_fixture::NAME.into(),
493 ));
494 }
495 tools
496}
497
498fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
499 let path = repository.configured_path()?;
500 if path.is_absolute() {
501 Ok(path)
502 } else {
503 Ok(std::env::current_dir()?.join(path))
504 }
505}
506
507fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
508 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
509 anyhow::bail!(
510 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
511 );
512 }
513 Ok(())
514}
515
516fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
517 matches!(
518 error,
519 ModelError::MissingCredentials(_)
520 | ModelError::Credentials(_)
521 | ModelError::UnsupportedProvider(_)
522 )
523}
524
525#[cfg(test)]
526#[path = "bootstrap_tests.rs"]
527mod tests;