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