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