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 display = crate::tui::AttachmentDisplaySettings::from_config(
171 &ConfigRepository::new(cli.config.clone()).load()?,
172 );
173 return Ok(EarlyDispatch::Handled(
174 crate::tui::run_attachment(id.as_deref(), display, HerdrReporter::from_env()).await,
175 ));
176 }
177 if matches!(cli.command, Some(Command::Update)) {
178 return Ok(EarlyDispatch::Handled(
179 update::run_update(env!("CARGO_PKG_VERSION")).await,
180 ));
181 }
182 if let Some(Command::Login {
183 provider,
184 device_auth,
185 }) = &cli.command
186 {
187 let config_repository = ConfigRepository::new(cli.config.clone());
188 let mut config = config_repository.load()?;
189 let config_path = absolute_config_path(&config_repository)?;
190 ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
191 crate::credential_store::initialize_from_config(&mut config, &config_path)?;
192 return Ok(EarlyDispatch::Handled(
193 login::run(provider, *device_auth).await,
194 ));
195 }
196 Ok(EarlyDispatch::Continue)
197}
198
199struct PreparedStartup {
200 cli: Cli,
201 catalog: crate::agent::DiscoveredAgentCatalog,
202 config: crate::config::Config,
203 config_repository: ConfigRepository,
204 first_run: Option<SetupEntry>,
205 cwd: std::path::PathBuf,
206 automation_prompt: Option<String>,
207 output_file: Option<std::path::PathBuf>,
208 output: OutputFormat,
209 max_steps: Option<NonZeroUsize>,
210 timeout: Option<Duration>,
211 bound_agent: super::agent_binding::BoundAgent,
212 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
213 provider_refresh: cli_config::ProviderRefreshStatus,
214 store: AppCredentialStore,
215}
216
217async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
218 let config_path = cli.config.clone();
219 let config_repository = ConfigRepository::new(config_path.clone());
220 let first_run = detect_first_run(&config_repository);
222 let mut config = config_repository.load()?;
223 config.providers.activate()?;
225 let absolute_config = absolute_config_path(&config_repository)?;
226 crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
227 let cwd = std::env::current_dir()?;
228 let automation_prompt = automation::prompt_for_command(&cli.command)?;
229 let (output_file, output, max_steps, timeout) = match &cli.command {
230 Some(Command::Run {
231 output_file,
232 output,
233 max_steps,
234 timeout,
235 ..
236 }) => (output_file.clone(), *output, *max_steps, *timeout),
237 _ => (None, OutputFormat::Text, None, None),
238 };
239 let catalog = Arc::new(crate::agent::AgentCatalog::discover(&cwd)?);
240 let selected_agent = cli.agent.as_deref().unwrap_or("default");
241 let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
242 let catalog = crate::agent::DiscoveredAgentCatalog::new(cwd.clone(), catalog);
244
245 let role = if automation_prompt.is_some() || matches!(cli.command, Some(Command::Acp)) {
248 AgentRole::AutomationRoot
249 } else {
250 AgentRole::InteractiveRoot
251 };
252
253 let store = AppCredentialStore;
254 if matches!(role, AgentRole::AutomationRoot) {
258 cli_config::refresh_custom_provider_models(&config, &store).await;
259 }
260 let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
261 let permission_mode_before_override = config.permission_mode;
262 let config_changed = cli_config::apply_overrides(&mut config, &cli)?;
263 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
264 tokio::spawn(rho_providers::model::models_dev::ensure_models_dev_catalog());
268 cli_config::normalize_reasoning_for_cli(
269 &mut config,
270 if cli.reasoning.is_some() {
271 rho_providers::model::ReasoningRequestSource::Explicit
272 } else {
273 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
274 },
275 )?;
276 if cli.save && config_changed {
281 let session_permission_mode = config.permission_mode;
282 config.permission_mode = permission_mode_before_override;
283 config_repository.save(&config)?;
284 config.permission_mode = session_permission_mode;
285 }
286 let reasoning_before_binding = config.reasoning;
287 let bound_agent = AgentBinder::bind(
288 definition,
289 AgentInvocation {
290 role,
291 available_tools: host_capabilities(&cli, &config, role),
292 },
293 &config,
294 )?;
295 config = bound_agent.rho_config().cloned().unwrap_or(config);
296 let bound_reasoning_source =
297 if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
298 rho_providers::model::ReasoningRequestSource::Explicit
299 } else {
300 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
301 };
302
303 Ok(PreparedStartup {
304 cli,
305 catalog,
306 config,
307 config_repository,
308 first_run,
309 cwd,
310 automation_prompt,
311 output_file,
312 output,
313 max_steps,
314 timeout,
315 bound_agent,
316 bound_reasoning_source,
317 provider_refresh,
318 store,
319 })
320}
321
322struct AutomationStartup<'a> {
323 prompt: String,
324 config: &'a crate::config::Config,
325 config_repository: &'a ConfigRepository,
326 cwd: std::path::PathBuf,
327 cli: &'a Cli,
328 bound_agent: super::agent_binding::BoundAgent,
329 output_file: Option<std::path::PathBuf>,
330 output: OutputFormat,
331 max_steps: Option<NonZeroUsize>,
332 timeout: Option<Duration>,
333 herdr: HerdrReporter,
334}
335
336async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
337 let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
338 automation::run(
339 startup.prompt,
340 automation::Startup {
341 config: startup.config,
342 config_path: absolute_config_path(startup.config_repository)?,
343 cwd: startup.cwd,
344 no_system_prompt: startup.cli.no_system_prompt,
345 no_tools: startup.cli.no_tools,
346 no_subagents: startup.cli.no_subagents,
347 usage_purpose: "agent",
348 parent_session_id: None,
349 agent: startup.bound_agent,
350 output_file: startup.output_file,
351 output: startup.output,
352 max_steps: startup.max_steps,
353 timeout: startup.timeout,
354 diagnostics,
355 herdr: startup.herdr,
356 host_input: None,
357 notice_poster: None,
358 steering_slot: None,
359 approval_session: None,
360 approval_classifier: None,
361 hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
362 },
363 )
364 .await
365}
366
367struct AcpCommandStartup {
368 config: crate::config::Config,
369 config_repository: ConfigRepository,
370 cwd: std::path::PathBuf,
371 cli: Cli,
372 bound_agent: super::agent_binding::BoundAgent,
373 herdr: HerdrReporter,
374}
375
376async fn run_acp_startup(startup: AcpCommandStartup) -> anyhow::Result<()> {
377 let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
378 acp::run(acp::AcpStartup {
379 config: startup.config,
380 config_path: absolute_config_path(&startup.config_repository)?,
381 cwd: startup.cwd,
382 no_system_prompt: startup.cli.no_system_prompt,
383 no_tools: startup.cli.no_tools,
384 no_subagents: startup.cli.no_subagents,
385 agent: startup.bound_agent,
386 diagnostics,
387 herdr: startup.herdr,
388 })
389 .await
390}
391
392struct InteractiveStartup<'a> {
393 cli: &'a Cli,
394 catalog: crate::agent::DiscoveredAgentCatalog,
395 config: crate::config::Config,
396 config_repository: ConfigRepository,
397 first_run: Option<SetupEntry>,
398 cwd: std::path::PathBuf,
399 bound_agent: super::agent_binding::BoundAgent,
400 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
401 herdr: HerdrReporter,
402}
403
404async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
405 let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
406
407 let pending_update_notice = startup
408 .config
409 .check_for_updates
410 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
411 let pending_custom_models = (!startup.config.providers.custom.is_empty()).then(|| {
412 let config = startup.config.clone();
413 tokio::spawn(
414 async move {
415 cli_config::refresh_custom_provider_models(&config, &AppCredentialStore).await;
416 }
417 .instrument(tracing::info_span!("startup.custom_models")),
418 )
419 });
420
421 let _scope = startup.config.providers.thread_scope()?;
422 let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
423 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
424 Arc::new(AppCredentialStore),
425 );
426 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
427 sdk_options.provider,
428 &credentials,
429 );
430 let (missing_auth_error, missing_auth_model_error) = match provider_result {
431 Ok(_) => (None, None),
432 Err(error) if is_interactive_startup_unavailable_error(&error) => {
433 (Some(error.to_string()), Some(error))
434 }
435 Err(error) => return Err(error.into()),
436 };
437 interactive::run(interactive::Startup {
438 cli: startup.cli,
439 catalog: startup.catalog,
440 config: startup.config,
441 config_path: absolute_config_path(&startup.config_repository)?,
442 config_repository: startup.config_repository,
443 cwd: startup.cwd,
444 first_run: startup.first_run,
445 missing_auth_error,
446 missing_auth_model_error,
447 pending_update_notice,
448 pending_custom_models,
449 diagnostics,
450 herdr: startup.herdr,
451 agent: startup.bound_agent,
452 reasoning_source: startup.bound_reasoning_source,
453 })
454 .await
455}
456
457fn bind_agent_diagnostics(
458 config: &crate::config::Config,
459 agent: &super::agent_binding::BoundAgent,
460) -> RuntimeDiagnostics {
461 let diagnostics = RuntimeDiagnostics::new(config);
462 diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
463 diagnostics
464}
465
466fn ensure_cli_credential_store_choice(
467 config: &mut crate::config::Config,
468 config_path: Option<std::path::PathBuf>,
469) -> anyhow::Result<()> {
470 use rho_providers::credentials::CredentialStoreBackend;
471 use std::io::{self, IsTerminal, Write};
472
473 let Some(request) = crate::credential_store::choice_request(config) else {
474 return Ok(());
475 };
476
477 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
478 anyhow::bail!(
479 "credential store is unset; set it before non-interactive login with \
480`rho credential-store set os|file`, behavior.credential_store in config.toml, \
481or RHO_CREDENTIAL_STORE=os|file"
482 );
483 }
484
485 let backends = request.available_backends();
486 if backends.is_empty() {
487 anyhow::bail!(
488 "no credential store backend is available (os: {}; file: {})",
489 request.os.detail,
490 request.file.detail
491 );
492 }
493
494 eprintln!("Choose where Rho stores provider credentials:");
495 eprintln!("This is saved to config and used for future logins on this machine.");
496 if request.os.available {
497 eprintln!(" [1] OS credential store (recommended)");
498 } else {
499 eprintln!(
500 " [1] OS credential store (unavailable: {})",
501 request.os.detail
502 );
503 }
504 if request.file.available {
505 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
506 } else {
507 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
508 }
509 let default_backend = request
510 .default_backend()
511 .unwrap_or(CredentialStoreBackend::Os);
512 let default_hint = match default_backend {
513 CredentialStoreBackend::Os => "1",
514 CredentialStoreBackend::File => "2",
515 };
516 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
517 io::stderr().flush()?;
518
519 let mut answer = String::new();
520 io::stdin().read_line(&mut answer)?;
521 let backend = match answer.trim() {
522 "" => default_backend,
523 "1" | "os" | "OS" => CredentialStoreBackend::Os,
524 "2" | "file" | "FILE" => CredentialStoreBackend::File,
525 other => {
526 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
527 }
528 };
529 if !backends.contains(&backend) {
530 let detail = request.detail_for(backend);
531 anyhow::bail!(
532 "{} credential store is unavailable: {detail}",
533 backend.as_str()
534 );
535 }
536
537 let path = crate::credential_store::set_backend(backend, config_path)?;
538 config.credential_store = Some(backend);
539 eprintln!(
540 "credential store set to {} in {}",
541 backend.as_str(),
542 path.display()
543 );
544 Ok(())
545}
546
547fn run_credential_store_command(
548 command: &CredentialStoreCommand,
549 config_path: Option<std::path::PathBuf>,
550) -> anyhow::Result<()> {
551 match command {
552 CredentialStoreCommand::Probe { backend } => {
553 let result = crate::credential_store::probe(*backend);
554 if result.available {
555 println!("available: {}", result.detail);
556 Ok(())
557 } else {
558 anyhow::bail!(result.detail)
559 }
560 }
561 CredentialStoreCommand::Status => {
562 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
564 None => println!("unset"),
565 Some(backend) => println!("{}", backend.as_str()),
566 }
567 Ok(())
568 }
569 CredentialStoreCommand::Set { backend } => {
570 let path = crate::credential_store::set_backend(*backend, config_path)?;
571 println!(
572 "credential store set to {} in {}",
573 backend.as_str(),
574 path.display()
575 );
576 Ok(())
577 }
578 }
579}
580
581pub(super) fn host_capabilities(
582 cli: &Cli,
583 config: &crate::config::Config,
584 role: AgentRole,
585) -> crate::agent::AgentCapabilities {
586 use crate::agent::ToolCapability;
587
588 if cli.no_tools {
589 return crate::agent::AgentCapabilities::default();
590 }
591 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
592 #[cfg(windows)]
594 tools.remove(&ToolCapability::Bash);
595 #[cfg(not(windows))]
596 tools.remove(&ToolCapability::Powershell);
597 if cli.no_subagents || !config.enable_subagents {
598 tools.remove(&ToolCapability::Agent);
599 tools.remove(&ToolCapability::Agents);
600 }
601 if role != AgentRole::InteractiveRoot {
602 tools.remove(&ToolCapability::Questionnaire);
603 }
604 #[cfg(debug_assertions)]
605 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
606 tools.insert(ToolCapability::Extension(
607 crate::tools::tui_fixture::NAME.into(),
608 ));
609 }
610 tools
611}
612
613pub(super) fn absolute_config_path(
614 repository: &ConfigRepository,
615) -> anyhow::Result<std::path::PathBuf> {
616 let path = repository.configured_path()?;
617 if path.is_absolute() {
618 Ok(path)
619 } else {
620 Ok(std::env::current_dir()?.join(path))
621 }
622}
623
624const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
627
628fn parse_first_run_override(value: &str) -> Option<SetupEntry> {
635 match value.trim().to_ascii_lowercase().as_str() {
636 "" | "0" | "false" | "no" => None,
637 "signin" | "sign-in" | "login" => Some(SetupEntry::SignIn),
638 "model" | "models" => Some(SetupEntry::ChooseModel),
639 _ => Some(SetupEntry::Auto),
640 }
641}
642
643fn detect_first_run(repository: &ConfigRepository) -> Option<SetupEntry> {
648 if let Ok(value) = std::env::var(FIRST_RUN_OVERRIDE_VAR) {
649 if let Some(entry) = parse_first_run_override(&value) {
650 return Some(entry);
651 }
652 }
653 repository
654 .configured_path()
655 .is_ok_and(|path| !path.exists())
656 .then_some(SetupEntry::Auto)
657}
658
659fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
660 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
661 anyhow::bail!(
662 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
663 );
664 }
665 Ok(())
666}
667
668fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
669 matches!(
670 error,
671 ModelError::MissingCredentials(_)
672 | ModelError::Credentials(_)
673 | ModelError::UnsupportedProvider(_)
674 )
675}
676
677#[cfg(test)]
678#[path = "bootstrap_tests.rs"]
679mod tests;