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 tokio::spawn(rho_providers::model::models_dev::ensure_models_dev_catalog());
225 cli_config::normalize_reasoning_for_cli(
226 &mut config,
227 if cli.reasoning.is_some() {
228 rho_providers::model::ReasoningRequestSource::Explicit
229 } else {
230 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
231 },
232 )?;
233 if cli.save && config_changed {
238 config_repository.save(&config)?;
239 }
240 let reasoning_before_binding = config.reasoning;
241 let role = if automation_prompt.is_some() {
242 AgentRole::AutomationRoot
243 } else {
244 AgentRole::InteractiveRoot
245 };
246 let bound_agent = AgentBinder::bind(
247 definition,
248 AgentInvocation {
249 role,
250 available_tools: host_capabilities(&cli, &config, role),
251 },
252 &config,
253 )?;
254 config = bound_agent.rho_config().cloned().unwrap_or(config);
255 let bound_reasoning_source =
256 if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
257 rho_providers::model::ReasoningRequestSource::Explicit
258 } else {
259 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
260 };
261
262 Ok(PreparedStartup {
263 cli,
264 config,
265 config_repository,
266 first_run,
267 cwd,
268 automation_prompt,
269 output_file,
270 output,
271 max_steps,
272 timeout,
273 bound_agent,
274 bound_reasoning_source,
275 provider_refresh,
276 store,
277 })
278}
279
280struct AutomationStartup<'a> {
281 prompt: String,
282 config: &'a crate::config::Config,
283 config_repository: &'a ConfigRepository,
284 cwd: std::path::PathBuf,
285 cli: &'a Cli,
286 bound_agent: super::agent_binding::BoundAgent,
287 output_file: Option<std::path::PathBuf>,
288 output: OutputFormat,
289 max_steps: Option<NonZeroUsize>,
290 timeout: Option<Duration>,
291 herdr: HerdrReporter,
292}
293
294async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
295 let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
296 automation::run(
297 startup.prompt,
298 automation::Startup {
299 config: startup.config,
300 config_path: absolute_config_path(startup.config_repository)?,
301 cwd: startup.cwd,
302 no_system_prompt: startup.cli.no_system_prompt,
303 no_tools: startup.cli.no_tools,
304 no_subagents: startup.cli.no_subagents,
305 usage_purpose: "agent",
306 parent_session_id: None,
307 agent: startup.bound_agent,
308 output_file: startup.output_file,
309 output: startup.output,
310 max_steps: startup.max_steps,
311 timeout: startup.timeout,
312 diagnostics,
313 herdr: startup.herdr,
314 host_input: None,
315 notice_poster: None,
316 steering_slot: None,
317 approval_session: None,
318 hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
319 },
320 )
321 .await
322}
323
324struct InteractiveStartup<'a> {
325 cli: &'a Cli,
326 config: crate::config::Config,
327 config_repository: ConfigRepository,
328 first_run: Option<SetupEntry>,
329 cwd: std::path::PathBuf,
330 bound_agent: super::agent_binding::BoundAgent,
331 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
332 herdr: HerdrReporter,
333}
334
335async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
336 let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
337
338 let pending_update_notice = startup
339 .config
340 .check_for_updates
341 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
342
343 let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
344 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
345 Arc::new(AppCredentialStore),
346 );
347 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
348 sdk_options.provider,
349 &credentials,
350 );
351 let (missing_auth_error, missing_auth_model_error) = match provider_result {
352 Ok(_) => (None, None),
353 Err(error) if is_interactive_startup_unavailable_error(&error) => {
354 (Some(error.to_string()), Some(error))
355 }
356 Err(error) => return Err(error.into()),
357 };
358 interactive::run(interactive::Startup {
359 cli: startup.cli,
360 config: startup.config,
361 config_path: absolute_config_path(&startup.config_repository)?,
362 config_repository: startup.config_repository,
363 cwd: startup.cwd,
364 first_run: startup.first_run,
365 missing_auth_error,
366 missing_auth_model_error,
367 pending_update_notice,
368 diagnostics,
369 herdr: startup.herdr,
370 agent: startup.bound_agent,
371 reasoning_source: startup.bound_reasoning_source,
372 })
373 .await
374}
375
376fn bind_agent_diagnostics(
377 config: &crate::config::Config,
378 agent: &super::agent_binding::BoundAgent,
379) -> RuntimeDiagnostics {
380 let diagnostics = RuntimeDiagnostics::new(config);
381 diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
382 diagnostics
383}
384
385fn ensure_cli_credential_store_choice(
386 config: &mut crate::config::Config,
387 config_path: Option<std::path::PathBuf>,
388) -> anyhow::Result<()> {
389 use rho_providers::credentials::CredentialStoreBackend;
390 use std::io::{self, IsTerminal, Write};
391
392 let Some(request) = crate::credential_store::choice_request(config) else {
393 return Ok(());
394 };
395
396 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
397 anyhow::bail!(
398 "credential store is unset; set it before non-interactive login with \
399`rho credential-store set os|file`, behavior.credential_store in config.toml, \
400or RHO_CREDENTIAL_STORE=os|file"
401 );
402 }
403
404 let backends = request.available_backends();
405 if backends.is_empty() {
406 anyhow::bail!(
407 "no credential store backend is available (os: {}; file: {})",
408 request.os.detail,
409 request.file.detail
410 );
411 }
412
413 eprintln!("Choose where Rho stores provider credentials:");
414 eprintln!("This is saved to config and used for future logins on this machine.");
415 if request.os.available {
416 eprintln!(" [1] OS credential store (recommended)");
417 } else {
418 eprintln!(
419 " [1] OS credential store (unavailable: {})",
420 request.os.detail
421 );
422 }
423 if request.file.available {
424 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
425 } else {
426 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
427 }
428 let default_backend = request
429 .default_backend()
430 .unwrap_or(CredentialStoreBackend::Os);
431 let default_hint = match default_backend {
432 CredentialStoreBackend::Os => "1",
433 CredentialStoreBackend::File => "2",
434 };
435 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
436 io::stderr().flush()?;
437
438 let mut answer = String::new();
439 io::stdin().read_line(&mut answer)?;
440 let backend = match answer.trim() {
441 "" => default_backend,
442 "1" | "os" | "OS" => CredentialStoreBackend::Os,
443 "2" | "file" | "FILE" => CredentialStoreBackend::File,
444 other => {
445 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
446 }
447 };
448 if !backends.contains(&backend) {
449 let detail = request.detail_for(backend);
450 anyhow::bail!(
451 "{} credential store is unavailable: {detail}",
452 backend.as_str()
453 );
454 }
455
456 let path = crate::credential_store::set_backend(backend, config_path)?;
457 config.credential_store = Some(backend);
458 eprintln!(
459 "credential store set to {} in {}",
460 backend.as_str(),
461 path.display()
462 );
463 Ok(())
464}
465
466fn run_credential_store_command(
467 command: &CredentialStoreCommand,
468 config_path: Option<std::path::PathBuf>,
469) -> anyhow::Result<()> {
470 match command {
471 CredentialStoreCommand::Probe { backend } => {
472 let result = crate::credential_store::probe(*backend);
473 if result.available {
474 println!("available: {}", result.detail);
475 Ok(())
476 } else {
477 anyhow::bail!(result.detail)
478 }
479 }
480 CredentialStoreCommand::Status => {
481 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
483 None => println!("unset"),
484 Some(backend) => println!("{}", backend.as_str()),
485 }
486 Ok(())
487 }
488 CredentialStoreCommand::Set { backend } => {
489 let path = crate::credential_store::set_backend(*backend, config_path)?;
490 println!(
491 "credential store set to {} in {}",
492 backend.as_str(),
493 path.display()
494 );
495 Ok(())
496 }
497 }
498}
499
500pub(super) fn host_capabilities(
501 cli: &Cli,
502 config: &crate::config::Config,
503 role: AgentRole,
504) -> crate::agent::AgentCapabilities {
505 use crate::agent::ToolCapability;
506
507 if cli.no_tools {
508 return crate::agent::AgentCapabilities::default();
509 }
510 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
511 #[cfg(windows)]
513 tools.remove(&ToolCapability::Bash);
514 #[cfg(not(windows))]
515 tools.remove(&ToolCapability::Powershell);
516 if cli.no_subagents || !config.enable_subagents {
517 tools.remove(&ToolCapability::Agent);
518 tools.remove(&ToolCapability::Agents);
519 }
520 if role != AgentRole::InteractiveRoot {
521 tools.remove(&ToolCapability::Questionnaire);
522 }
523 #[cfg(debug_assertions)]
524 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
525 tools.insert(ToolCapability::Extension(
526 crate::tools::tui_fixture::NAME.into(),
527 ));
528 }
529 tools
530}
531
532pub(super) fn absolute_config_path(
533 repository: &ConfigRepository,
534) -> anyhow::Result<std::path::PathBuf> {
535 let path = repository.configured_path()?;
536 if path.is_absolute() {
537 Ok(path)
538 } else {
539 Ok(std::env::current_dir()?.join(path))
540 }
541}
542
543const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
546
547fn parse_first_run_override(value: &str) -> Option<SetupEntry> {
554 match value.trim().to_ascii_lowercase().as_str() {
555 "" | "0" | "false" | "no" => None,
556 "signin" | "sign-in" | "login" => Some(SetupEntry::SignIn),
557 "model" | "models" => Some(SetupEntry::ChooseModel),
558 _ => Some(SetupEntry::Auto),
559 }
560}
561
562fn detect_first_run(repository: &ConfigRepository) -> Option<SetupEntry> {
567 if let Ok(value) = std::env::var(FIRST_RUN_OVERRIDE_VAR) {
568 if let Some(entry) = parse_first_run_override(&value) {
569 return Some(entry);
570 }
571 }
572 repository
573 .configured_path()
574 .is_ok_and(|path| !path.exists())
575 .then_some(SetupEntry::Auto)
576}
577
578fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
579 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
580 anyhow::bail!(
581 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
582 );
583 }
584 Ok(())
585}
586
587fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
588 matches!(
589 error,
590 ModelError::MissingCredentials(_)
591 | ModelError::Credentials(_)
592 | ModelError::UnsupportedProvider(_)
593 )
594}
595
596#[cfg(test)]
597#[path = "bootstrap_tests.rs"]
598mod tests;