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