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